mrpunyapal / rector-pest
Rector upgrade rules for Pest - refactoring and best practices for Pest testing framework
Fund package maintenance!
Requires
- php: ^8.2
- mrpunyapal/peststan: ^0.2.5
- rector/rector: ^2.0
- symplify/rule-doc-generator-contracts: ^11.2
Requires (Dev)
- laravel/pint: ^1.18
- nunomaduro/pao: ^0.1.4
- pestphp/pest: ^3.0 || ^4.0
- phpstan/phpstan: ^2.1
- symplify/rule-doc-generator: ^12.2
This package is auto-updated.
Last update: 2026-05-10 18:03:08 UTC
README
Rector rules for PestPHP to improve code quality and help with version upgrades.
Rector Pest now also exposes a semantic remediation layer for safe Pest-specific diagnostics. Canonical issue identifiers, semantic metadata, and diagnostic-to-fix mapping live inside this package so Rector rules can stay independent from any single analyzer while still remaining ready for future PestStan interoperability.
Available Rules
See all available Pest rules here. See the semantic architecture and interoperability contract here.
Installation
composer require --dev mrpunyapal/rector-pest
Available Rule Sets
Code Quality
Improve your Pest tests with better readability and expressiveness.
The code-quality set also fixes a small set of PestStan-aligned anti-patterns, including static Pest callbacks that actually require instance binding, invalid beforeAll()/afterAll() usage inside describe() blocks, invalid literal repeat() counts, and redundant literal type expectations when another matcher keeps the chain meaningful. Empty test closures and impossible literal/type combinations remain modeled in the semantic registry, but they are not auto-fixed when the runtime semantics would change or the intent would become ambiguous.
These semantic fixes do not require PestStan at runtime for package consumers. This repository still keeps PestStan as a development-only dependency for its own PHPStan configuration, while Rector Pest itself owns the canonical issue registry and consumes analyzer diagnostics through stable identifiers instead of direct analyzer coupling.
// rector.php use RectorPest\Set\PestSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withSets([ PestSetList::PEST_CODE_QUALITY, ]);
| Set | Description |
|---|---|
PestSetList::PEST_CODE_QUALITY |
Converts expect() assertions to use Pest's built-in matchers for better readability |
PestSetList::PEST_CHAIN |
Merges multiple expect() calls into chained expectations and optimizes their order. |
PestSetList::PEST_LARAVEL |
Laravel-specific rules (requires illuminate/support): converts Str:: equality checks to Pest string case matchers |
PestSetList::PEST_MIGRATION |
PHPUnit → Pest migration rules (opt-in): converts assertions, data providers, and test structure |
PestSetList::PEST_BROWSER |
Pest Browser code-quality rules (requires pestphp/pest-plugin-browser): converts expect($page->getter()) patterns to dedicated browser assertion methods |
Version Upgrade Sets
Use PestLevelSetList to automatically upgrade to a specific Pest version. Sets for higher versions include sets for lower versions.
// rector.php use RectorPest\Set\PestLevelSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withSets([ PestLevelSetList::UP_TO_PEST_40, ]);
| Set | Description |
|---|---|
PestLevelSetList::UP_TO_PEST_30 |
Upgrade from Pest v2 to v3 |
PestLevelSetList::UP_TO_PEST_40 |
Upgrade from Pest v2/v3 to v4 (includes v3 changes) |
Manual Version Configuration
Use PestSetList if you only want changes for a specific version:
// rector.php use RectorPest\Set\PestSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withSets([ PestSetList::PEST_30, // Only v2→v3 changes ]);
| Set | Description |
|---|---|
PestSetList::PEST_30 |
Pest v2 → v3 migration rules |
PestSetList::PEST_40 |
Pest v3 → v4 migration rules |
Chaining Expectations
The PEST_CHAIN set automatically merges multiple expect() calls into a single chained expression.
// rector.php use RectorPest\Set\PestSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withSets([ PestSetList::PEST_CODE_QUALITY, PestSetList::PEST_CHAIN, ]);
Before:
expect($value1)->toBe(10); expect($value1)->toBeInt(); expect($value2)->toBe(20); expect($value2)->toBeString(); expect($value3)->toBe(30);
After:
expect($value1)->toBe(10) ->toBeInt() ->and($value2)->toBe(20) ->toBeString() ->and($value3)->toBe(30);
Formatting rules (requires rector/rector 2.4.1+):
- The first matcher after
expect()stays on the same line asexpect() - The first matcher after
->and()stays on the same line as->and() - Every additional matcher in a segment goes on its own indented line
->not->toBeX()is treated as a single unit and stays inline
Note: On
rector/rectorversions older than 2.4.1, chaining still works but all method calls are printed inline on a single line.
PHPUnit to Pest Migration
The PEST_MIGRATION set helps convert PHPUnit test patterns to Pest equivalents. This is an opt-in set — review changes carefully after applying.
// rector.php use RectorPest\Set\PestSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withSets([ PestSetList::PEST_MIGRATION, ]);
Included rules:
| Rule | Description |
|---|---|
ConvertAssertToExpectRector |
Converts $this->assert*() calls to expect()-> chains |
ConvertExpectExceptionToThrowRector |
Converts $this->expectException*() plus the throwing call to expect(fn() => ...)->toThrow() |
Pest Browser Testing
The PEST_BROWSER set improves code quality of tests written with pestphp/pest-plugin-browser. It converts verbose expect($page->getter()) patterns into the plugin's dedicated browser assertion methods, producing clearer failure messages and more readable tests.
Requirement: The target project must have
pestphp/pest-plugin-browserinstalled.
// rector.php use RectorPest\Set\PestSetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests/Browser', ]) ->withSets([ PestSetList::PEST_BROWSER, ]);
Included rules:
| Rule | Transforms |
|---|---|
UseBrowserValueAssertionsRector |
expect($page->value($sel))->toBe($v) → $page->assertValue($sel, $v) and negated form → assertValueIsNot |
UseBrowserAriaAndDataAttributeAssertionsRector |
expect($page->attribute($sel, 'aria-*'))->toBe($v) → $page->assertAriaAttribute($sel, $attr, $v) and data-* form → assertDataAttribute |
UseBrowserAttributeAssertionsRector |
expect($page->attribute($sel, $attr))->toBe/toContain/not->toContain/toBeNull → assertAttribute, assertAttributeContains, assertAttributeDoesntContain, assertAttributeMissing |
UseBrowserSourceAssertionsRector |
expect($page->content())->toContain($html) → assertSourceHas and negated form → assertSourceMissing |
UseBrowserScriptAssertionsRector |
expect($page->script($expr))->toBe/toEqual($v) → $page->assertScript($expr, $v) |
UseBrowserUrlAssertionsRector |
expect($page->url())->toBe($url) → $page->assertUrlIs($url) |
URL assertions scope: only
assertUrlIsis covered because it is the only URL-related assertion that has a directexpect($page->getter())->toBe()equivalent. Path, scheme, host, port, query-string, and fragment assertions (assertPathIs,assertSchemeIs,assertHostIs, etc.) have noexpect()counterparts in the plugin and are out of scope.
Aria/data attribute assertions scope:
assertAriaAttributeandassertDataAttributeare covered byUseBrowserAriaAndDataAttributeAssertionsRector. Note that the plugin methods accept the attribute name without thearia-/data-prefix — the rule strips the prefix automatically.
Before:
expect($page->value('input[name=email]'))->toBe('test@example.com'); expect($page->attribute('button', 'aria-label'))->toBe('Close'); expect($page->attribute('div', 'data-id'))->toBe('123'); expect($page->attribute('img', 'alt'))->toBe('Profile Picture'); expect($page->attribute('div', 'class'))->toContain('container'); expect($page->attribute('div', 'class'))->not->toContain('hidden'); expect($page->attribute('button', 'disabled'))->toBeNull(); expect($page->content())->toContain('<h1>Welcome</h1>'); expect($page->content())->not->toContain('<div class="error">'); expect($page->script('document.title'))->toBe('Home Page'); expect($page->script('window.scrollY'))->toEqual(0); expect($page->url())->toBe('https://example.com/home');
After:
$page->assertValue('input[name=email]', 'test@example.com'); $page->assertAriaAttribute('button', 'label', 'Close'); $page->assertDataAttribute('div', 'id', '123'); $page->assertAttribute('img', 'alt', 'Profile Picture'); $page->assertAttributeContains('div', 'class', 'container'); $page->assertAttributeDoesntContain('div', 'class', 'hidden'); $page->assertAttributeMissing('button', 'disabled'); $page->assertSourceHas('<h1>Welcome</h1>'); $page->assertSourceMissing('<div class="error">'); $page->assertScript('document.title', 'Home Page'); $page->assertScript('window.scrollY', 0); $page->assertUrlIs('https://example.com/home');
Using Individual Rules
You can also use individual rules instead of sets:
// rector.php use RectorPest\Rules\ChainExpectCallsRector; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([ __DIR__ . '/tests', ]) ->withRules([ ChainExpectCallsRector::class, ]);
Running Rector
# Preview changes vendor/bin/rector process --dry-run # Apply changes vendor/bin/rector process
Semantic Interoperability
Rector Pest separates three concerns:
- deterministic semantic analyzers that only return local facts needed for a safe transformation
- a canonical issue registry with stable identifiers and metadata for interoperability
- an interop layer that maps external diagnostics onto canonical issues and safe Rector fixes
That split keeps the transformation layer conservative. For example, redundant literal type cleanup only removes checks when the literal value is deterministic and the surrounding chain does not branch or transform the expectation subject. Static callback cleanup also distinguishes nested Pest callbacks from nested non-Pest closure trees so outer describe() callbacks are not rewritten just because an inner hook needs instance binding.
The current semantic contract is documented in docs/semantic-architecture.md.
Requirements
- PHP 8.2+
- Rector 2.0+
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
The MIT License (MIT). Please see License File for more information.