| Install | |
|---|---|
composer require helgesverre/pest-to-phpunit |
|
| Latest Version: | v0.0.7 |
| PHP: | ^8.1 |
[!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.
composer require --dev helgesverre/pest-to-phpunit
rector.php<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use HelgeSverre\PestToPhpUnit\Set\PestToPhpUnitSetList;
return RectorConfig::configure()
->withSets([
PestToPhpUnitSetList::PEST_TO_PHPUNIT,
]);
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;).
# 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
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/
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);
}
}
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);
}
}
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'))();
}
}
// 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).
#[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);
}
}
| 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 |
| 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| 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 |
| 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 |
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeGreaterThan / toBeLessThan |
✅ | assertGreaterThan / assertLessThan |
toBeGreaterThanOrEqual / toBeLessThanOrEqual |
✅ | assertGreaterThanOrEqual / assertLessThanOrEqual |
toBeBetween($min, $max) |
✅ | assertGreaterThanOrEqual + assertLessThanOrEqual |
toEqualWithDelta |
✅ | assertEqualsWithDelta |
toEqualCanonicalizing |
✅ | assertEqualsCanonicalizing |
| 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 |
| 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 |
| 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) |
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeFile / toBeDirectory |
✅ | assertFileExists / assertDirectoryExists |
toBeReadableFile / toBeWritableFile |
✅ | assertFileIsReadable / assertFileIsWritable |
toBeReadableDirectory / toBeWritableDirectory |
✅ | assertDirectoryIsReadable / assertDirectoryIsWritable |
| 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 |
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toHaveSnakeCaseKeys / toHaveKebabCaseKeys |
✅ | foreach (array_keys(...)) + regex assert |
toHaveCamelCaseKeys / toHaveStudlyCaseKeys |
✅ | foreach (array_keys(...)) + regex assert |
| 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 |
| Pest Assertion | Status | PHPUnit Equivalent |
|---|---|---|
toBeCollection |
✅ | assertInstanceOf(Collection::class) |
toBeModel |
✅ | assertInstanceOf(Model::class) |
toBeEloquentCollection |
✅ | assertInstanceOf(EloquentCollection::class) |
| 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() |
| 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 |
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 |
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: ...') |
// 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 |
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.
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:
$this->toBeGreaterThan(0) chains (single or multiple statements)expect() calls + $this->value accessfn () => $this->toBeInstanceOf(Collection::class)->not->toCustom() correctly inverts the inlined assertions->each modifier — expect([1,2,3])->each->toBePositive()// TODO(Pest) comment| Pest Feature | Notes |
|---|---|
Higher-order test methods (e.g. it('...')->assertTrue()) |
Not converted |
beforeAll/afterAll inside describe() |
No clean PHPUnit equivalent without multiple classes |
expect()->extend() are inlined automatically, though complex bodies may require manual review.snake_case with a test_ prefix. Long describe() chains can produce long method names.toBeSnakeCase, toBeUuid, etc.) use regex approximations that may not match Pest's exact validation logic.git clone https://github.com/HelgeSverre/pest-to-phpunit.git
cd pest-to-phpunit
composer install
# Run tests
vendor/bin/phpunit
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.
MIT. See LICENSE.
Built by Helge Sverre on top of Rector and nikic/php-parser.