Testing Rules
How to test a rule with RuleTester: the fixture files .code, .expected and
.violations, decision values in values.neon and in the fixture header, the target PHP version, and
everything the tester checks for you.
Fixtures
Each rule is tested on a directory of fixtures of its own. A fixture is a group of up to three files with the same name:
basic.codeis the code before the fix;basic.expectedis the code after the fix; when the file is missing, the rule must not change the code;basic.violationsare the expected reports, each on its own line, as a line number and a message:
3: The message of an exception must end with a period.
6: The message of an exception must end with a period.
The report of a risky fix is followed by an indented line that names what would decide the risk, plus the reason if the rule gave one, so the tester also checks the cause of the risk:
4: The loop must be written with `array_any()`.
risky TypeUnknown: the loop may go through an object, which `array_any()` does not take
The fixture must give a value to the decisions the rule enforces; otherwise they are keep and the rule does
nothing, and the tester then insists that the fixture changes nothing and reports nothing. The values for the whole directory go
into the file values.neon next to the fixtures, written the same way as in the configuration:
acme:
debugCalls: forbidden
In comments at its start, a fixture can add: values just for itself, as JSON layered on top of values.neon; the
PHP version it is written for; the maximum line length to check against; and the word risky, which also allows the
rule to make fixes that change the behavior of the code:
<?php
// {"mistakes.debugOutputFunctions": ["var_dump", "dump"]}
// php 8.4
// lineLength 80
// risky
A key is written with its full path. For a rule whose decisions are in a single section, the key without the section is
enough, so {"debugCalls": "keep"} means acme.debugCalls. It also works for a key the rule reads but does
not own, like the engine parameter mistakes.debugOutputFunctions here. When no length is given, lines are measured
against 120 characters, as in the standards. When no version is given, the rule is tested on the version it asked for in
requires, otherwise on PHP 8.0; a version passed to the tester with the parameter $phpVersion wins over
the header. It is never the version of the interpreter running the tests, because the verdict of a rule should not depend on the
computer. Without risky, a risky occurrence is only reported, and the file .expected must leave it as it
was; a fixture with risky is also proof for the reference that the rule has such fixes.
Headers tell what namespaces outside the fixture declare. They are written like the items of a use statement, just
like the keys namespaces and nameResolution in the
configuration:
<?php
// namespacedFunctions App\helper, App\Utils\{format}
// namespacedConstants App\LIMIT
// nameResolution certain
A rule with typesRequired gets the types of the code from the PHPStan of the project in which the tests run,
computed on the fixture and on the declarations from the subdirectory stubs/ of the fixture directory. A rule that
only benefits from types gets them where the directory stubs/ exists, and a fixture with // types off in
its header does not get them even there. This is how you test what the rule recognizes without types, only from the declarations
in view.
A fixture is not an example for the documentation: it should be ugly and full of edge cases. A comment in the middle of a construct, a construct on one line and across three, an empty body, an interpolated string, alternative syntax. These are exactly the places where rules break.
RuleTester
use DressCode\Testing\RuleTester;
use Tester\Assert;
Assert::noError(fn() => RuleTester::run(ExceptionMessagePeriodRule::class, __DIR__ . '/fixtures/exceptionMessagePeriod'));
The method run() goes through all *.code files in the directory and returns their count; a directory
without them is a failure, not an empty success. A failure is the exception DressCode\Testing\TestFailure with the
name of the fixture and a diff, so every test framework shows it clearly. Nette Tester wants at least one assertion in every test,
which is why the call in the example is wrapped in Assert::noError(); with PHPUnit, it is a single call in a test
method.
For every fixture, the tester checks:
- the output equals the file
.expected(or the input, when there is no.expected); - the reports equal the file
.violations, line by line and in the same order, and the message of each of them ends with a period; - idempotence: running the rule again on its own output changes nothing;
- comments: the output contains all comments from the input, unless the rule has
modifiesComments; - suppression: with the comment
dresscode:ignoreFilein the header, the rule reports nothing and changes nothing; - the contract of fixes: no change of the tree without a successful report, no report under a decision the rule did not
declare, no report without
fixable: falsethat is still there after a pass that changed nothing, and no risky violation left in a fixture with// risky, where the fix was allowed; - the rest: what the rule reports in the last pass is exactly what a fresh run reports on the output;
- the tree: every node has the correct parent, which is what a wrong insertion breaks.
If a rule needs something in its constructor, pass a factory fn(array $values): Rule instead of its class. The
factory gets the values from the header of the fixture as they are written in the JSON, or an empty array when there is no header,
and returns the finished rule; with a factory, the tester does not call configure() and does not check the reports
against the values of the decisions. A rule that asks for its plugin's own analysis (here ClockRule asks for the
analysis Clock, which the engine cannot build by itself) gets its factory through the parameter
analyses, in the same shape as the key analyses of the configuration and of the plugin manifest:
RuleTester::run(ClockRule::class, __DIR__ . '/fixtures/clock', analyses: [
Clock::class => fn(FileNode $file, string $path) => new Clock('noon'),
]);
For special cases there are smaller tools: RuleTester::runFixture() runs a single fixture,
RuleTester::check() checks a finished instance of a rule on a string without files (useful for a quick reproduction),
RuleTester::collectViolations() returns the reports on a fixture directly in the form of a .violations
file, and collectOutput() the output in the form of an .expected file, so you can have them written out
and only check the diff instead of copying them by hand. run(), runFixture() and both
collect*() methods accept $phpVersion and analyses. The method check() also
accepts the whole style used to process the code (style: new Style(indent: ' ', maxLineLength: 80)) instead of the
longest line from the header.
Data for Upgrading Libraries
A package that supplies upgrade data instead of rules, or next to them,
tests the data with the class Testing\UpgradingTester. The method
collectProblems($file, $root, $packageRules) reads the file the way a run would read it in a project whose
vendor/ contains the library, and returns the problems found as sentences; an empty array means the file is fine. You
pass the rules the package supplies itself as the third parameter. The method collectPackageProblems($root) checks
that extra.dresscode.packages in the composer.json of the package names exactly the libraries its files
talk about. The method runSample() then fixes a sample of code in the old API the way a project with the data turned
on would fix it, and returns a FileResult with the fixed code and the findings. As the third parameter, it takes the
decisions of the package's own rules that the data do not set, grouped by section. Both are described in detail on the page Replacement Maps.
A Test on Real Code
A rule test checks the rule by itself. Only a run on real code shows how it behaves together with the other rules. The option
dresscode check --strict-rules turns every broken contract into an error instead of a warning, and
--jobs 1 lets everything run in a single process, where it is easy to debug.
Before you publish a rule, run fix on a large amount of someone else's code, for example on the directory
vendor/, and go through the diff. The tester checks idempotence and comments, but not taste.