Skip to content

Commit ca0a9f6

Browse files
Merge pull request #4 from DaveLiddament/feature/improve-dx
Improve DX
2 parents 4dc5854 + 514ff9c commit ca0a9f6

17 files changed

+479
-127
lines changed

Diff for: README.md

+121-101
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
# PHPStan rule testing helper
22

3-
This is a helper library for slight improvement to DX for testing PHPStan rules.
4-
It allows you to write the expected error message in the fixture file.
5-
Anything after `// ERROR ` is considered the expected error message.
6-
The test classes are simplified as you now specify just the fixture files, and this library will extract the expected error and calculate the correct line number.
3+
This library offers a couple of improvements to PHPStan's [custom rule test harness](https://phpstan.org/developing-extensions/testing#custom-rules).
74

8-
You can also use an `ErrorMessageFormatter` to further decouple tests from the actual error message.
9-
See [ErrorMessageFormatter](#error-formatter) section.
5+
This library provides [AbstractRuleTestCase](src/AbstractRuleTestCase.php), which extends PHPStan's `RuleTestCase`.
106

11-
## Example
7+
It offers a simpler way to write tests for custom rules. Specifically:
8+
9+
1. No need to specify line numbers in the test code.
10+
2. You can specify the expected error message once.
11+
12+
## Improvement 1: No more line numbers in tests
13+
14+
The minimal test case specifies the Rule being tested and at least one test.
15+
Each test must call the `assertIssuesReported` method, which takes the path of one or more fixture files.
1216

13-
Test code extends [AbstractRuleTestCase](src/AbstractRuleTestCase.php).
14-
As with the PHPStan's `RuleTestCase` use the `getRule` method to setup the rule used by the test.
15-
For each test list the fixture file(s) needed by the test, using the `assertIssuesReported` method.
1617

1718
#### Test code:
1819
```php
19-
use DaveLiddament\PhpstanRuleTestingHelper\AbstractRuleTestCase;
20+
use DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase;
2021

2122
class CallableFromRuleTest extends AbstractRuleTestCase
2223
{
@@ -46,154 +47,173 @@ class SomeCode
4647
}
4748
```
4849

49-
Every line that contains `// ERROR ` is considered an issue that should be picked up by the rule.
50+
Every line that contains `// ERROR ` is considered an issue that should be picked up by the rule.
5051
The text after `// ERROR` is the expected error message.
5152

52-
With this approach you don't need to work out the line number of the error.
53-
There are further benefits by using the [ErrorMessageFormatter](#error-formatter) to decouple the error messages from the test code.
54-
55-
56-
57-
NOTE: You can pass in multiple fixture files. E.g.
58-
```php
59-
$this->assertIssuesReported(
60-
__DIR__ . '/Fixtures/SomeCode.php',
61-
__DIR__ . '/Fixtures/SomeCode2.php',
62-
// And so on...
63-
);
64-
```
65-
66-
67-
## Installation
68-
69-
```shell
70-
composer require --dev dave-liddament/phpstan-rule-test-helper
71-
```
72-
73-
## Error Formatter
74-
75-
The chances are when you developing PHPStan rules the error message for violations will change.
76-
Making any change will require you to update all the related tests.
77-
78-
### Constant string error messages
53+
With this approach you don't need to work out the line number of the error.
54+
This is particularly handy when you update the Fixture file, you no longer have to update all the line numbers in the test.
7955

80-
In the simplest case the error is a message that does provide any context, other than line number.
81-
E.g. in the example the error is `Can not call method`. No additional information (e.g. who was trying to call the method) is provided.
8256

83-
Create a class that extends `ConstantErrorFormatter` and pass the error message to the constructor.
57+
# Improvement 2: Specify the expected error message once
8458

85-
Update the test to tell it to use a `ConstantStringErrorMessageFormatter`.
59+
Often you end up writing the same error message for every violation. To get round this use the `getErrorFromatter` method to specify the error message.
8660

61+
#### Test code:
8762
```php
88-
class CallableFromRuleTest extends AbstractRuleTestCase
63+
use DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase;
64+
65+
class CallableFromRuleTest extends AbstractRuleTestCase
8966
{
90-
// getRule method omitted for brevity
91-
// testAllowedCall method omitted for brevity
67+
protected function getRule(): Rule
68+
{
69+
return new CallableFromRule($this->createReflectionProvider());
70+
}
9271

93-
protected function getErrorFormatter(): ErrorMessageFormatter
72+
public function testAllowedCall(): void
73+
{
74+
$this->assertIssuesReported(__DIR__ . '/Fixtures/SomeCode.php');
75+
}
76+
77+
protected function getErrorFormatter(): string
9478
{
95-
return new ConstantStringErrorMessageFormatter("Can not call method");
79+
return "Can not call method";
9680
}
9781
}
9882
```
9983

100-
Now if the error message is changed, the text only needs to be updated in one place.
101-
102-
Finally, the fixture can be simplified.
103-
There is no need to specify the error message in the fixture file, we just need to specify where the error is.
84+
The fixture file is simplified as there is no need to specify the error message. Any lines where an error is expected need to end with `// ERROR`.
85+
#### Fixture:
10486

105-
Updated fixture:
106-
```php
87+
```php
10788
class SomeCode
10889
{
10990
public function go(): void
11091
{
11192
$item = new Item("hello");
11293
$item->updateName("world"); // ERROR
11394
}
95+
96+
public function go2(): void
97+
{
98+
$item = new Item("hello");
99+
$item->remove(); // ERROR
100+
}
114101
}
115102
```
116103

117-
### Error messages with context
118-
119-
Good error message will provide context.
120-
For example, the error message could be improved to give the name of the calling class.
121-
The calling class is `SomeClass` so let's update the error message to `Can not call method from SomeCode`.
104+
### Adding context to error messages
122105

123-
The fixture is updated to include the calling class name after `// ERROR`
106+
Good error message require context. The context is added to the fixture file after `// ERROR `. Multiple pieces of context can be added by separating them with the `|` character.
124107

108+
#### Test code:
125109
```php
126-
class SomeCode
110+
use DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase;
111+
112+
class CallableFromRuleTest extends AbstractRuleTestCase
127113
{
128-
public function go(): void
114+
protected function getRule(): Rule
129115
{
130-
$item = new Item("hello");
131-
$item->updateName("world"); // ERROR SomeCode
116+
return new CallableFromRule($this->createReflectionProvider());
117+
}
118+
119+
public function testAllowedCall(): void
120+
{
121+
$this->assertIssuesReported(__DIR__ . '/Fixtures/SomeCode.php');
122+
}
123+
124+
protected function getErrorFormatter(): string
125+
{
126+
return "Can not call {0} from within class {1}";
132127
}
133128
}
134129
```
135130

136-
The `CallableFromRuleErrorFormatter` is updated.
137-
Firstly it now extends `ErrorMessageFormatter` instead of `ConstantErrorFormatter`.
138-
An implementation of `getErrorMessage` is added.
139-
This is passed everything after `\\ ERROR`, with whitespace trimmed from each side, and must return the expected error message.
131+
The fixture file is simplified as there is no need to specify the error message. Any lines where an error is expected need to end with `// ERROR`.
132+
#### Fixture:
140133

141-
```php
142-
class CallableFromRuleErrorFormatter extends ErrorMessageFormatter
134+
```php
135+
class SomeCode
143136
{
144-
public function getErrorMessage(string $errorContext): string
137+
public function go(): void
138+
{
139+
$item = new Item("hello");
140+
$item->updateName("world"); // ERROR Item::updateName|SomeCode
141+
}
142+
143+
public function go2(): void
145144
{
146-
return 'Can not call method from ' . $errorContext;
145+
$item = new Item("hello");
146+
$item->remove(); // ERROR Item::remove|SomeCode
147147
}
148148
}
149149
```
150150

151-
### Error message helper methods
151+
The expected error messages would be:
152+
153+
- Line 6: `Can not call Item::updateName from within class SomeCode`
154+
- Line 11: `Can not call Item::remove from within class SomeCode`
152155

153-
Sometimes the contextual error messages might have 2 or more pieces of information.
154-
Continuing the example above, the error message could be improved to give the name of the calling class and the method being called.
155-
E.g. `Can not call Item::updateName from SomeCode`.
156+
### More flexible error messages
156157

157-
The fixture is updated to include both `Item::updateName` and `SomeCode` seperated by the `|` character.
158+
If you need more flexibility in the error message, you can return an object that implements the `ErrorMessageFormatter` [interface](src/ErrorMessageFormatter.php).
158159

159-
E.g. `// ERROR`
160+
In the example below the message changes depending on the number of parts in the error context.
160161

161162
```php
162-
class SomeCode
163+
use DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase;
164+
165+
class CallableFromRuleTest extends AbstractRuleTestCase
163166
{
164-
public function go(): void
167+
protected function getRule(): Rule
168+
{
169+
return new CallableFromRule($this->createReflectionProvider());
170+
}
171+
172+
public function testAllowedCall(): void
165173
{
166-
$item = new Item("hello");
167-
$item->updateName("world"); // ERROR Item::updateName|SomeCode
174+
$this->assertIssuesReported(__DIR__ . '/Fixtures/SomeCode.php');
168175
}
176+
177+
protected function getErrorFormatter(): ErrorMessageFormatter
178+
{
179+
new class() extends ErrorMessageFormatter {
180+
public function getErrorMessage(string $errorContext): string
181+
{
182+
$parts = $this->getErrorMessageAsParts($errorContext);
183+
$calledFrom = count($parts) === 2 ? 'class '.$parts[1] : 'outside an object';
184+
185+
return sprintf('Can not call %s from %s', $parts[0], $calledFrom);
186+
}
187+
};
188+
}
169189
}
170190
```
171191

172-
Use the `getErrorMessageAsParts` helper method to do this, as shown below:
173-
174-
```php
192+
#### Fixture:
175193

176-
class CallableFromRuleErrorFormatter extends ErrorMessageFormatter
194+
```php
195+
class SomeCode
177196
{
178-
public function getErrorMessage(string $errorContext): string
197+
public function go(): void
179198
{
180-
$parts = $this->getErrorMessageAsParts($errorContext, 2);
181-
return sprintf('Can not call %s from %s', $parts[0], $parts[1]);
199+
$item = new Item("hello");
200+
$item->updateName("world"); // ERROR Item::updateName|SomeCode
182201
}
183202
}
203+
204+
$item = new Item("hello");
205+
$item->remove(); // ERROR Item::remove
184206
```
185207

186-
The signature of `getErrorMessageAsParts` is:
208+
The expected error messages would be:
209+
210+
- Line 6: `Can not call Item::updateName from class SomeCode`
211+
- Line 11: `Can not call Item::remove from outside an object`
212+
187213

188-
```php
189-
/**
190-
* @return list<string>
191-
*/
192-
protected function getErrorMessageAsParts(
193-
string $errorContext,
194-
int $expectedNumberOfParts,
195-
string $separator = '|',
196-
): array
197-
```
198214

199-
If you use the `getErrorMessageAsParts` and the number of parts is not as expected, the test will error with a message that tells you file and line number of the invalid error.
215+
## Installation
216+
217+
```shell
218+
composer require --dev dave-liddament/phpstan-rule-test-helper
219+
```

Diff for: fixtures/goto.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<?php
22

3-
goto foo; // ERROR goto statement is not allowed
3+
goto foo; // ERROR foo
44

55
foo:
66
echo 'foo';
7-
goto bar; // ERROR goto statement is not allowed
7+
goto bar; // ERROR bar
88

99
bar:
1010
echo 'bar';

Diff for: fixtures/gotoWithMessageInComment.php

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
goto foo; // ERROR goto statement is not allowed. Label: foo
4+
5+
foo:
6+
echo 'foo';
7+
goto bar; // ERROR goto statement is not allowed. Label: bar
8+
9+
bar:
10+
echo 'bar';

Diff for: src/AbstractRuleTestCase.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace DaveLiddament\PhpstanRuleTestHelper;
66

77
use DaveLiddament\PhpstanRuleTestHelper\Internal\FixtureFileReader;
8+
use DaveLiddament\PhpstanRuleTestHelper\Internal\InvalidFixtureFile;
89
use PHPStan\Testing\RuleTestCase;
910

1011
/**
@@ -14,10 +15,16 @@
1415
*/
1516
abstract class AbstractRuleTestCase extends RuleTestCase
1617
{
18+
/** @throws InvalidFixtureFile */
1719
final protected function assertIssuesReported(string ...$fixtureFiles): void
1820
{
1921
$fixtureFileReader = new FixtureFileReader();
2022
$errorFormatter = $this->getErrorFormatter();
23+
24+
if (is_string($errorFormatter)) {
25+
$errorFormatter = new StringErrorMessageConverter($errorFormatter);
26+
}
27+
2128
$expectedErrors = [];
2229
foreach ($fixtureFiles as $fixture) {
2330
$expectedErrors = array_merge(
@@ -29,7 +36,7 @@ final protected function assertIssuesReported(string ...$fixtureFiles): void
2936
$this->analyse($fixtureFiles, $expectedErrors);
3037
}
3138

32-
protected function getErrorFormatter(): ErrorMessageFormatter
39+
protected function getErrorFormatter(): ErrorMessageFormatter|string
3340
{
3441
return new DefaultErrorMessageFormatter();
3542
}

Diff for: src/ConstantStringErrorMessageFormatter.php

+15
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
namespace DaveLiddament\PhpstanRuleTestHelper;
66

7+
/**
8+
* @deprecated From v 0.3.0 this is no longer needed.`
9+
*
10+
* Before 0.3.0 `getErrorFormatter` had to return an instance of `ErrorMessageFormatter`.
11+
*
12+
* public function getErrorFormatter() {
13+
* return new ConstantStringErrorMessageFormatter('My error message');
14+
* }
15+
*
16+
* Since 0.3.0 you can return a string or an instance of `ErrorMessageFormatter`.
17+
*
18+
* public function getErrorFormatter() {
19+
* return 'My error message';
20+
* }
21+
*/
722
class ConstantStringErrorMessageFormatter extends ErrorMessageFormatter
823
{
924
public function __construct(

0 commit comments

Comments
 (0)