helgesverre/pest-to-phpunit

Rector rules to convert Pest tests to PHPUnit

Installs: 5

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

Type:rector-extension

pkg:composer/helgesverre/pest-to-phpunit

v0.0.7 2026-02-16 23:50 UTC

This package is auto-updated.

Last update: 2026-02-16 23:50:35 UTC


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.

Latest Version on Packagist Total Downloads PHP Version License

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.phpnamespace 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-fakerfake() Converted to \Faker\Factory::create(), locale arg preserved
pest-plugin-fakerfake('pt_PT') Converted to \Faker\Factory::create('pt_PT')
pest-plugin-fakerWithFaker 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->value access
  • Arrow functionsfn () => $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
  • ->each modifierexpect([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_case with a test_ prefix. Long describe() 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.