helgesverre / pest-to-phpunit
Rector rules to convert Pest tests to PHPUnit
Package info
github.com/HelgeSverre/pest-to-phpunit
Type:rector-extension
pkg:composer/helgesverre/pest-to-phpunit
Requires
- php: ^8.1
- rector/rector: ^2.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5|^11.0
- symplify/easy-coding-standard: ^12.0
Suggests
- ta-tikoma/phpunit-architecture-test: Required for converted arch() tests to run (provides ArchitectureAsserts trait)
README
Warning
This project is experimental. It handles many common Pest patterns, but edge cases may produce incorrect output. Always review the generated code before committing.
A Rector extension that automatically converts Pest test files into PHPUnit test classes.
Handles test() / it() blocks, hooks, datasets, expect() assertion chains, modifiers, and more — getting you most of the way there automatically while leaving clear TODO markers for anything that needs manual review.
Installation
composer require --dev helgesverre/pest-to-phpunit
Usage
1. Add the set to your rector.php
<?php declare(strict_types=1); use Rector\Config\RectorConfig; use HelgeSverre\PestToPhpUnit\Set\PestToPhpUnitSetList; return RectorConfig::configure() ->withSets([ PestToPhpUnitSetList::PEST_TO_PHPUNIT, ]);
Namespace inference (recommended)
Pest test files typically have no namespace declaration. If your project uses PSR-4 autoloading for tests (e.g. "Tests\\": "tests/" in composer.json), enable namespace inference to automatically add the correct namespace to generated classes:
<?php declare(strict_types=1); use Rector\Config\RectorConfig; use HelgeSverre\PestToPhpUnit\Rector\PestFileToPhpUnitClassRector; return RectorConfig::configure() ->withConfiguredRule(PestFileToPhpUnitClassRector::class, [ PestFileToPhpUnitClassRector::INFER_NAMESPACE => true, ]);
This reads your composer.json autoload-dev and autoload PSR-4 mappings to derive the namespace from the file path (e.g. tests/Feature/ExampleTest.php → namespace Tests\Feature;).
2. Run Rector
# Preview changes (dry run) vendor/bin/rector process tests --dry-run # Apply changes vendor/bin/rector process tests # Only a specific folder vendor/bin/rector process tests/Feature
3. Review TODO comments
Some Pest features cannot be auto-converted and will be marked with // TODO(Pest): comments. After running Rector, search for these to find anything that needs manual attention:
grep -rn "TODO(Pest)" tests/
Examples
Basic test() / it()
Before:
test('adds numbers', function () { expect(1 + 1)->toBe(2); }); it('subtracts numbers', function () { expect(5 - 3)->toBe(2); });
After:
class BasicTest extends \PHPUnit\Framework\TestCase { public function test_adds_numbers(): void { $this->assertSame(2, 1 + 1); } public function test_it_subtracts_numbers(): void { $this->assertSame(2, 5 - 3); } }
describe() blocks
// Before describe('Auth', function () { it('logs in', function () { expect(true)->toBeTrue(); }); }); // After class DescribeBlocksTest extends \PHPUnit\Framework\TestCase { public function test_it_auth_logs_in(): void { $this->assertTrue(true); } }
Hooks → setUp / tearDown
// Before beforeEach(function () { $this->user = new User(); }); afterEach(function () { $this->user = null; }); test('user exists', function () { expect($this->user)->not->toBeNull(); }); // After class HooksTest extends \PHPUnit\Framework\TestCase { protected $user; protected function setUp(): void { parent::setUp(); $this->user = new User(); } protected function tearDown(): void { parent::tearDown(); $this->user = null; } public function test_user_exists(): void { $this->assertNotNull($this->user); } }
uses() → extends + traits
// Before uses(Tests\TestCase::class, RefreshDatabase::class); test('database works', function () { expect(true)->toBeTrue(); }); // After class UsesTraitsTest extends \Tests\TestCase { use RefreshDatabase; public function test_database_works(): void { $this->assertTrue(true); } }
Modifiers: skip(), todo(), throws(), group()
// Before test('skipped test', function () { expect(true)->toBeTrue(); })->skip('Not ready yet'); test('todo test', function () { })->todo(); test('throws exception', function () { throw new RuntimeException('fail'); })->throws(RuntimeException::class, 'fail'); test('grouped test', function () { expect(true)->toBeTrue(); })->group('unit'); // After class ModifiersTest extends \PHPUnit\Framework\TestCase { public function test_skipped_test(): void { $this->markTestSkipped('Not ready yet'); } public function test_todo_test(): void { $this->markTestIncomplete('TODO'); } public function test_throws_exception(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('fail'); throw new RuntimeException('fail'); } #[\PHPUnit\Framework\Attributes\Group('unit')] public function test_grouped_test(): void { $this->assertTrue(true); } }
expect()->toThrow()
// Before test('throws exception', function () { expect(fn () => throw new RuntimeException('boom')) ->toThrow(RuntimeException::class, 'boom'); }); // After class ToThrowTest extends \PHPUnit\Framework\TestCase { public function test_throws_exception(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('boom'); (fn () => throw new RuntimeException('boom'))(); } }
Pest Faker Plugin
// Before use function Pest\Faker\fake; test('generates a name', function () { $name = fake()->name; expect($name)->toBeString(); }); test('with locale', function () { $name = fake('pt_PT')->name; expect($name)->toBeString(); }); // After class FakerTest extends \PHPUnit\Framework\TestCase { public function test_generates_a_name(): void { $name = \Faker\Factory::create()->name; $this->assertIsString($name); } public function test_with_locale(): void { $name = \Faker\Factory::create('pt_PT')->name; $this->assertIsString($name); } }
The use function Pest\Faker\fake; import is automatically removed. $this->faker via the WithFaker trait works naturally through trait conversion (see uses() above).
Datasets → #[DataProvider]
// Before dataset('emails', [ 'test@example.com', 'foo@bar.com', ]); test('validates email', function (string $email) { expect($email)->toContain('@'); })->with('emails'); // After class DatasetTest extends \PHPUnit\Framework\TestCase { public static function emails(): array { return [ 'test@example.com', 'foo@bar.com', ]; } #[\PHPUnit\Framework\Attributes\DataProvider('emails')] public function test_validates_email(string $email): void { $this->assertContains('@', $email); } }
Feature Support
Core Constructs
| Pest Feature | Status | PHPUnit Output |
|---|---|---|
test() / it() |
✅ | public function test_*(): void |
describe() (nested, 4+ levels deep) |
✅ | Method name prefixing |
beforeEach / afterEach |
✅ | setUp() / tearDown() |
beforeAll / afterAll |
✅ | setUpBeforeClass() / tearDownAfterClass() |
uses(TestCase::class) |
✅ | extends TestCase |
uses(Trait::class) |
✅ | use Trait; |
covers(Foo::class) |
✅ | #[CoversClass(Foo::class)] |
coversNothing() |
✅ | #[CoversNothing] |
dataset('name', [...]) |
✅ | Static data provider method |
dataset('name', fn() => ...) |
✅ | Generator-based provider |
Describe-scoped beforeEach/afterEach |
✅ | Inlined into test methods (try/finally for afterEach) |
| Non-Pest code preserved | ✅ | Kept alongside generated class |
Test Modifiers
| Modifier | Status | PHPUnit Output |
|---|---|---|
->skip('reason') |
✅ | $this->markTestSkipped(...) |
->skip($condition, 'reason') |
✅ | Conditional if + markTestSkipped |
->todo() |
✅ | $this->markTestIncomplete('TODO') |
->group('name') |
✅ | #[Group('name')] |
->depends('test') |
✅ | #[Depends('test_*')] |
->covers(Foo::class) |
✅ | #[CoversClass(Foo::class)] |
->with('dataset') |
✅ | #[DataProvider('dataset')] |
->with([...]) |
✅ | Inline provider method + #[DataProvider] |
Multiple ->with() (cross-join) |
✅ | Composed cross-join provider method |
->throws(Exception::class) |
✅ | expectException + expectExceptionMessage |
->after(fn() => ...) |
✅ | Test body wrapped in try/finally |
->repeat(N) |
✅ | for loop wrapping test body |
->only() |
✅ | #[Group('only')] |
expect() Assertions
Type Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeString / toBeInt / toBeFloat / toBeArray |
✅ | assertIsString / assertIsInt / assertIsFloat / assertIsArray |
toBeBool / toBeCallable / toBeIterable |
✅ | assertIsBool / assertIsCallable / assertIsIterable |
toBeNumeric / toBeObject / toBeResource / toBeScalar |
✅ | assertIsNumeric / assertIsObject / assertIsResource / assertIsScalar |
toBeInstanceOf |
✅ | assertInstanceOf |
Value Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBe |
✅ | assertSame |
toEqual |
✅ | assertEquals |
toBeTrue / toBeFalse |
✅ | assertTrue / assertFalse |
toBeTruthy / toBeFalsy |
✅ | assertNotEmpty / assertEmpty |
toBeNull |
✅ | assertNull |
toBeEmpty |
✅ | assertEmpty |
toBeJson |
✅ | assertJson |
toBeNan / toBeFinite / toBeInfinite |
✅ | assertNan / assertIsFinite / assertIsInfinite |
Comparison Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeGreaterThan / toBeLessThan |
✅ | assertGreaterThan / assertLessThan |
toBeGreaterThanOrEqual / toBeLessThanOrEqual |
✅ | assertGreaterThanOrEqual / assertLessThanOrEqual |
toBeBetween($min, $max) |
✅ | assertGreaterThanOrEqual + assertLessThanOrEqual |
toEqualWithDelta |
✅ | assertEqualsWithDelta |
toEqualCanonicalizing |
✅ | assertEqualsCanonicalizing |
String Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toStartWith / toEndWith |
✅ | assertStringStartsWith / assertStringEndsWith |
toMatch |
✅ | assertMatchesRegularExpression |
toContain (string subject) |
✅ | assertStringContainsString |
toContain (array subject) |
✅ | assertContains |
toContain($a, $b, $c) (multi-arg) |
✅ | Multiple assertContains calls |
Array / Collection Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toHaveCount |
✅ | assertCount |
toHaveLength |
✅ | assertSame($n, is_string($x) ? strlen($x) : count($x)) |
toHaveKey |
✅ | assertArrayHasKey |
toHaveKeys(['a', 'b']) |
✅ | Multiple assertArrayHasKey calls |
toContainEqual |
✅ | assertContainsEquals |
toContainOnlyInstancesOf |
✅ | assertContainsOnlyInstancesOf |
toHaveSameSize |
✅ | assertSameSize |
toBeList |
✅ | assertIsList |
toBeIn([$a, $b]) |
✅ | assertContains($subject, $haystack) |
toMatchArray / toMatchObject |
✅ | assertEquals |
Object Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toHaveProperty('name') |
✅ | assertObjectHasProperty |
toHaveProperties(['a', 'b']) |
✅ | Multiple assertObjectHasProperty calls |
toHaveProperties(['name' => 'John']) |
✅ | assertSame per key-value pair |
toHaveMethod('foo') |
✅ | assertTrue(method_exists(...)) |
toMatchConstraint($c) |
✅ | assertThat($subject, $constraint) |
File / Directory Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeFile / toBeDirectory |
✅ | assertFileExists / assertDirectoryExists |
toBeReadableFile / toBeWritableFile |
✅ | assertFileIsReadable / assertFileIsWritable |
toBeReadableDirectory / toBeWritableDirectory |
✅ | assertDirectoryIsReadable / assertDirectoryIsWritable |
String Format Assertions (via regex)
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeUppercase / toBeLowercase |
✅ | assertSame(strtoupper($x), $x) / assertSame(strtolower($x), $x) |
toBeAlpha / toBeAlphaNumeric / toBeDigits |
✅ | assertMatchesRegularExpression |
toBeSnakeCase / toBeKebabCase / toBeCamelCase / toBeStudlyCase |
✅ | assertMatchesRegularExpression |
toBeUuid / toBeUrl |
✅ | assertMatchesRegularExpression |
Array Key Format Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toHaveSnakeCaseKeys / toHaveKebabCaseKeys |
✅ | foreach (array_keys(...)) + regex assert |
toHaveCamelCaseKeys / toHaveStudlyCaseKeys |
✅ | foreach (array_keys(...)) + regex assert |
Exception Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toThrow(Exception::class) |
✅ | expectException + invoke callable |
toThrow(Exception::class, 'msg') |
✅ | expectException + expectExceptionMessage |
toThrow(new Exception('msg')) |
✅ | expectException + expectExceptionMessage |
toThrow('message') |
✅ | expectExceptionMessage |
not->toThrow() |
✅ | try/catch with $this->fail() on exception |
Laravel-Specific Assertions
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeCollection |
✅ | assertInstanceOf(Collection::class) |
toBeModel |
✅ | assertInstanceOf(Model::class) |
toBeEloquentCollection |
✅ | assertInstanceOf(EloquentCollection::class) |
Chain Modifiers
| Modifier | Status | Behavior |
|---|---|---|
->not->* |
✅ | Negated equivalents (assertNotSame, assertNotNull, etc.) |
->and($subject) |
✅ | Split into multiple assertion groups |
->each->* (no closure) |
✅ | foreach loop with assertion per item |
->tap(fn() => ...) |
✅ | Closure body inlined |
->pipe(fn($v) => ...) |
✅ | Subject transformed: (fn($v) => ...)($subject) |
Property access (e.g. ->name) |
✅ | $subject->name |
Method access (e.g. ->count()) |
✅ | $subject->count() |
Pest Plugins
| Plugin | Status | Behavior |
|---|---|---|
pest-plugin-faker — fake() |
✅ | Converted to \Faker\Factory::create(), locale arg preserved |
pest-plugin-faker — fake('pt_PT') |
✅ | Converted to \Faker\Factory::create('pt_PT') |
pest-plugin-faker — WithFaker trait |
✅ | Works via uses() trait conversion, $this->faker preserved |
use function Pest\Faker\fake; |
✅ | Import automatically removed |
use function Pest\Laravel\{get, post}; |
✅ | Grouped imports automatically removed |
use function Pest\Livewire\livewire; |
✅ | Import automatically removed |
Silently Stripped (debug/dev-only)
These Pest methods are removed from the chain without emitting any output:
| Modifier | Reason |
|---|---|
->dd() / ->ddWhen() / ->ddUnless() |
Debug — dump and die |
->ray() |
Debug — Ray debugger |
->json() |
Output modifier — no assertion equivalent |
->defer() |
Timing modifier — no assertion equivalent |
Converted to markTestSkipped ⚠️
These features have no PHPUnit equivalent and are converted to skipped tests with a review comment:
| Pest Feature | PHPUnit Output |
|---|---|
arch() tests |
$this->markTestSkipped('Arch test not supported in PHPUnit: ...') |
Higher-order it('...')->expect([...])->toBeUsed() |
$this->markTestSkipped('Arch test not supported in PHPUnit: ...') |
Emits // TODO Comment ⚠️
These features emit a TODO comment because they require manual conversion:
| Pest Feature | TODO Comment |
|---|---|
->sequence(...) |
// TODO(Pest): ->sequence() requires manual conversion to PHPUnit |
->match(...) |
// TODO(Pest): ->match() requires manual conversion to PHPUnit |
->scoped(...) |
// TODO(Pest): ->scoped() requires manual conversion to PHPUnit |
->each(fn() => ...) (with closure) |
// TODO(Pest): ->each(closure) requires manual conversion to PHPUnit |
->when(...) / ->unless(...) |
// TODO(Pest): ->when()/->unless() requires manual conversion to PHPUnit |
Unknown ->toXxx() expectations |
// TODO(Pest): Unknown expectation ->toXxx() has no PHPUnit equivalent |
Laravel / Livewire assert*() Methods ✅
When expect() wraps a Laravel TestResponse, Livewire Testable, or any object with assert*() methods, they are emitted as direct method calls on the subject:
// Before expect($this->get('/'))->assertOk()->assertSee('Welcome'); // After $this->get('/')->assertOk()->assertSee('Welcome');
This works automatically for all assert*() methods — no special mapping needed since these methods already throw PHPUnit assertions internally. Verified coverage includes:
Laravel TestResponse:
| Category | Methods |
|---|---|
| Status | assertOk, assertCreated, assertNotFound, assertForbidden, assertUnauthorized, assertUnprocessable, assertStatus, assertSuccessful, assertNoContent |
| Content | assertSee, assertDontSee, assertSeeText, assertSeeInOrder, assertSeeTextInOrder |
| JSON | assertJson, assertExactJson, assertJsonFragment, assertJsonMissing, assertJsonStructure, assertJsonCount, assertJsonPath, assertJsonValidationErrors, assertJsonMissingValidationErrors |
| Redirects | assertRedirect, assertRedirectContains, assertRedirectToRoute, assertLocation |
| Headers | assertHeader, assertHeaderMissing |
| Validation | assertValid, assertInvalid, assertSessionHasErrors |
| Session | assertSessionHas, assertSessionHasAll, assertSessionMissing |
| Views | assertViewIs, assertViewHas, assertViewHasAll, assertViewMissing |
| Cookies | assertCookie, assertCookieMissing, assertCookieExpired |
| Downloads | assertDownload |
Livewire Testable:
| Category | Methods |
|---|---|
| Content | assertSee, assertDontSee, assertSeeHtml, assertDontSeeHtml, assertSeeInOrder |
| Properties | assertSet, assertNotSet, assertCount |
| Events | assertDispatched, assertNotDispatched |
| Validation | assertHasErrors, assertHasNoErrors |
| Navigation | assertRedirect, assertRedirectToRoute, assertNoRedirect |
| Other | assertStatus, assertForbidden, assertUnauthorized, assertViewHas, assertViewIs, assertFileDownloaded |
Non-assert methods in the chain (like followRedirects(), set(), call()) are preserved naturally as chained method calls.
Custom Expectations (expect()->extend()) ✅
Custom expectations defined via expect()->extend('name', fn) are parsed and inlined at call sites:
// Before expect()->extend('toBeWithinRange', function (int $min, int $max) { return $this->toBeGreaterThanOrEqual($min) ->toBeLessThanOrEqual($max); }); test('value in range', function () { expect(100)->toBeWithinRange(90, 110); }); // After class CustomExpectTest extends \PHPUnit\Framework\TestCase { public function test_value_in_range(): void { $this->assertGreaterThanOrEqual(90, 100); $this->assertLessThanOrEqual(110, 100); } }
Supports:
- Delegating bodies —
$this->toBeGreaterThan(0)chains (single or multiple statements) - Mixed bodies — local variables +
expect()calls +$this->valueaccess - Arrow functions —
fn () => $this->toBeInstanceOf(Collection::class) - Parameter substitution — closure params mapped to call-site args, with default values
- Negation —
->not->toCustom()correctly inverts the inlined assertions - Composition — custom expectations calling other custom expectations
->eachmodifier —expect([1,2,3])->each->toBePositive()- Complex bodies — arbitrary code gets best-effort conversion with a
// TODO(Pest)comment
Not Supported
| Pest Feature | Notes |
|---|---|
Higher-order test methods (e.g. it('...')->assertTrue()) |
Not converted |
beforeAll/afterAll inside describe() |
No clean PHPUnit equivalent without multiple classes |
Limitations
- Not a 100% migration tool. Some Pest features have no direct PHPUnit equivalent — these are converted to skipped tests or TODO comments prompting manual review.
- Assertion coverage is broad (60+ mappings including negations). Custom expectations via
expect()->extend()are inlined automatically, though complex bodies may require manual review. - Method naming uses
snake_casewith atest_prefix. Longdescribe()chains can produce long method names. - File structure — the rule generates a class in-place. You may need to manually adjust namespaces or file locations.
- String format assertions (
toBeSnakeCase,toBeUuid, etc.) use regex approximations that may not match Pest's exact validation logic.
Development
git clone https://github.com/HelgeSverre/pest-to-phpunit.git cd pest-to-phpunit composer install # Run tests vendor/bin/phpunit
Adding test fixtures
Test fixtures live in tests/Rector/Fixture/ as .php.inc files with the format:
<?php
// Pest input code here
-----
<?php
// Expected PHPUnit output here
If the file should remain unchanged (no Pest code), omit the ----- separator.
License
MIT. See LICENSE.
Credits
Built by Helge Sverre on top of Rector and nikic/php-parser.