mrpunyapal / peststan
PHPStan extension for Pest PHP testing framework
Fund package maintenance!
Requires
- php: ^8.2
- phpstan/phpstan: ^2.0
Requires (Dev)
- laravel/pint: ^1.18
- mrpunyapal/rector-pest: ^0.2.0
- nunomaduro/pao: ^0.1.4
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.0
README
PHPStan extension for Pest PHP testing framework. Provides type-safe expectations, proper $this binding in test closures, and accurate return types for all Pest functions.
Requirements
- PHP ^8.2
- PHPStan ^2.0
- Pest PHP ^3.0, ^4.0, or ^5.0
Installation
composer require --dev mrpunyapal/peststan
If you have phpstan/extension-installer (recommended), the extension is registered automatically.
Otherwise, add it manually to your phpstan.neon or phpstan.neon.dist:
includes: - vendor/mrpunyapal/peststan/extension.neon
Features
Generic expect() Function
The extension provides generic type inference for Pest's expect() function, so PHPStan knows the exact type of the expectation value:
expect('hello'); // Expectation<string> expect(42); // Expectation<int> expect(['a' => 1]); // Expectation<array{a: int}> expect($user); // Expectation<User> expect(); // Expectation<null>
Type Narrowing Assertions
Type-checking assertion methods narrow the generic type parameter, so PHPStan tracks the type through assertion chains:
/** @var int|string $value */ $value = getValue(); expect($value)->toBeString(); // PHPStan now knows the expectation wraps a string expect($value)->toBeInstanceOf(User::class); // PHPStan now knows the expectation wraps a User
Supported type-narrowing assertions:
toBeString, toBeInt, toBeFloat, toBeBool, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeResource, toBeTrue, toBeFalse, toBeNull, toBeInstanceOf.
Type-Safe and() Chaining
The and() method properly changes the generic type parameter, enabling type-safe assertion chains:
expect('hello') ->toBeString() // Expectation<string> ->and(42) // Expectation<int> ->toBeInt() // Expectation<int> ->and(['a', 'b']) // Expectation<array{string, string}> ->toHaveCount(2); // Expectation<array{string, string}>
$this Binding in Test Closures
The extension ensures $this is properly typed inside all Pest test closures and lifecycle hooks. It auto-detects your TestCase class from your Pest.php configuration file:
// tests/Pest.php uses(Tests\TestCase::class)->in('Feature'); // tests/Feature/ExampleTest.php it('can access test case methods', function () { $this->get('/'); // PHPStan knows $this is Tests\TestCase }); beforeEach(function () { $this->assertTrue(true); // Works in hooks too });
Supported functions: it(), test(), describe(), beforeEach(), afterEach(), beforeAll(), afterAll().
Dynamic Properties in Test Closures
Pest allows setting properties on $this inside beforeEach/beforeAll hooks. The extension reads those assignments and infers the exact type — no @var annotation or extra local variable required:
beforeEach(function () { $this->post = new Post; // Post $this->title = 'Hello'; // 'Hello' (constant string) $this->count = 42; // 42 (constant int) $this->active = true; // true }); it('knows the property types', function () { $this->post->title; // PHPStan knows $this->post is Post — no "Cannot access property on mixed" error strlen($this->title); // fine — PHPStan knows it is a string });
For method-call chains such as factory calls, annotate the local variable with @var to guide inference:
beforeEach(function () { /** @var User $user */ $user = User::factory()->create(); $this->user = $user; // User });
If the same property is set by multiple hooks the type is unioned:
beforeEach(function () { $this->item = new Post; }); beforeEach(function () { $this->item = new Comment; }); it('sees the union', function () { $this->item; // Post|Comment });
Properties that are never set in a hook remain mixed.
Configuration
Automatic TestCase Detection
PestStan reads your Pest.php files to determine which TestCase class is used in each test directory. It supports the uses() pattern:
// uses(TestCase::class)->in('Feature');
No configuration needed — it discovers Pest.php files automatically from your PHPStan paths.
Manual TestCase Override
If auto-detection doesn't work for your setup, or you want a global default, set it in your phpstan.neon:
parameters: peststan: testCaseClass: App\Testing\TestCase
Explicit Pest.php Paths
If your Pest.php files aren't within PHPStan's analysis paths, you can specify them explicitly:
parameters: peststan: pestConfigFiles: - tests/Pest.php
Pest Function Return Types
Accurate return types for all Pest global functions:
| Function | Return Type |
|---|---|
expect($value) |
Expectation<TValue> |
it() / test() / todo() |
TestCall |
describe() |
DescribeCall |
not() and each() Return Types
expect('hello')->not(); // OppositeExpectation<string> expect([1, 2])->each(); // EachExpectation<array{int, int}>
TestCall Chaining
All TestCall methods are properly typed for fluent chaining:
it('does something', function () { /* ... */ }) ->with(['a', 'b']) ->group('unit', 'feature') ->skip(false) ->depends('other test') ->throws(RuntimeException::class) ->repeat(3);
Architecture Testing Support
Architecture testing methods are fully supported:
expect('App\Models') ->toExtend('Illuminate\Database\Eloquent\Model') ->ignoring('App\Models\Legacy'); expect('App') ->classes() ->toBeFinal(); expect('App\Actions')->toBeInvokable(); expect('App\DTOs')->toBeReadonly(); expect('App')->toUseStrictTypes();
Static Analysis Rules
PestStan ships with rules that catch common Pest mistakes at static analysis time, before your tests run.
pest.emptyTestClosure — Empty test body
Detects tests whose closure contains no statements.
it('does something'); // fine — todo test it('does something', function () {}); // ✘ Test 'does something' has an empty closure body. Did you forget to add assertions?
pest.staticTestClosure — Static test closure
Pest binds $this inside every test closure to the TestCase instance. Marking the closure static prevents that binding.
it('example', static function () { // ✘ Test closure passed to it() must not be static. expect(true)->toBeTrue(); });
pest.beforeAllInDescribe / pest.afterAllInDescribe — Lifecycle hooks inside describe()
Pest does not support beforeAll() or afterAll() inside describe() blocks — calling them throws at runtime.
describe('suite', function () { beforeAll(function () { /* ... */ }); // ✘ beforeAll() cannot be used inside describe() blocks. afterAll(function () { /* ... */ }); // ✘ afterAll() cannot be used inside describe() blocks. });
pest.repeatInvalidValue — Invalid repeat() count
repeat() requires a positive integer greater than zero.
it('runs multiple times', function () { /* ... */ })->repeat(0); // ✘ repeat() requires a value greater than 0, got 0.
pest.duplicateTestDescription — Duplicate test description
Two tests in the same file with the same description will collide at runtime.
it('does something', fn () => expect(1)->toBe(1)); it('does something', fn () => expect(2)->toBe(2)); // ✘ A test with the description 'it does something' already exists in this file.
pest.impossibleExpectation — Assertion that always fails
When the static type already makes an assertion impossible, PestStan reports it.
expect(42)->toBeString(); // ✘ Calling toBeString() on Expectation<int> will always fail. expect('hello')->toBeNull(); // ✘ Calling toBeNull() on Expectation<string> will always fail.
Covered assertions: toBeString, toBeInt, toBeFloat, toBeBool, toBeTrue, toBeFalse, toBeNull, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeInstanceOf.
pest.redundantExpectation — Assertion that always passes
When the static type already guarantees an assertion will always succeed, the assertion is redundant and adds no value.
expect(true)->toBeTrue(); // ✘ Calling toBeTrue() on Expectation<true> will always pass — the assertion is redundant. expect('hello')->toBeString(); // ✘ Calling toBeString() on Expectation<string> will always pass — the assertion is redundant. expect(42)->toBeNumeric(); // ✘ Calling toBeNumeric() on Expectation<int> will always pass — the assertion is redundant.
Covered assertions: toBeString, toBeInt, toBeFloat, toBeBool, toBeTrue, toBeFalse, toBeNull, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeInstanceOf.
pest.expectationRequiresIterable / pest.expectationRequiresString — Incompatible value type
Some expectation methods require the value to satisfy a pre-condition.
expect(42)->each(fn ($e) => $e->toBeInt()); // ✘ Calling each() on Expectation<int> — value is not iterable. expect(42)->toBeJson(); // ✘ Calling toBeJson() on Expectation<int> — value must be a string.
Methods requiring an iterable: each, sequence.
Methods requiring a string: json, toStartWith, toEndWith, toBeJson, toBeDirectory, toBeFile, toBeReadableFile, toBeWritableFile, toBeReadableDirectory, toBeWritableDirectory.
pest.beforeAllThisUsage — $this inside beforeAll()
beforeAll() runs once in a static context before any tests in the file. $this is not available.
beforeAll(function () { $this->db = new Database; // ✘ beforeAll() runs in static context — $this is not available. Use beforeEach() instead. });
Use beforeEach() to run setup before each test with $this available.
pest.todoWithClosure — ->todo() with a closure body
->todo() marks a test as pending and the closure is never executed — any code inside the closure is dead code.
it('should validate email', function () { expect(validateEmail('test@example.com'))->toBeTrue(); })->todo(); // ✘ Test 'should validate email' is marked as todo() but still has a closure body — the code will not execute.
Options:
- Remove the closure body:
it('should validate email')->todo()— pure pending placeholder. - Use
->skip()to preserve the code but skip execution. - Remove
->todo()to make the test run.
pest.missingAssertion — Test with no assertions
A test closure that neither calls expect() nor any assert*() method provides no safety guarantees.
it('processes the order', function () { $order = Order::create(['total' => 100]); $order->process(); // ✘ Test 'processes the order' has no assertions. Did you forget expect()? });
pest.throwsClassNotFound / pest.invalidThrowsException — Invalid throws() argument
throws() accepts a class name that implements Throwable. Passing a non-existent class or a class that is not Throwable is caught at analysis time.
it('fails', function () { ... })->throws('App\NonExistentException'); // ✘ Class App\NonExistentException passed to throws() does not exist. it('fails', function () { ... })->throws(stdClass::class); // ✘ throws() expects a Throwable class, got stdClass.
pest.coversClassNotFound / pest.coversFunctionNotFound — Non-existent symbol in coversClass()
coversClass(), coversTrait(), and coversFunction() reference symbols by name. PestStan verifies those symbols exist.
it('covers something', function () { ... })->coversClass('App\Nonexistent\Service'); // ✘ Class App\Nonexistent\Service referenced in coversClass() does not exist.
pest.describeWithoutTests — Empty describe() block
A describe() block that contains no it() or test() calls (only hooks, or nothing at all) is likely a mistake.
describe('UserService', function () { beforeEach(fn () => null); // ✘ describe() block 'UserService' contains no tests. });
pest.invalidGroupName — Empty group() name
group() requires at least one non-empty, non-whitespace string argument.
it('example', fn () => null)->group(''); // ✘ group() requires a non-empty string argument.
Ignoring rules
All rules use PHPStan identifiers, so you can suppress them selectively in your baseline or inline:
# phpstan.neon parameters: ignoreErrors: - identifier: pest.emptyTestClosure
/** @phpstan-ignore pest.staticTestClosure */ it('example', static fn () => expect(true)->toBeTrue());
Testing
composer test # Run all checks (lint + types + unit) composer lint # Apply code style fixes (Rector + Pint) composer test:lint # Check code style (dry-run) composer test:types # Run PHPStan analysis composer test:unit # Run Pest unit tests
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT License. See LICENSE for more information.