Try Live
Add Docs
Rankings
Pricing
Enterprise
Docs
Install
Install
Docs
Pricing
Enterprise
More...
More...
Try Live
Rankings
Add Docs
PestStan
https://github.com/mrpunyapal/peststan
Admin
PestStan is a PHPStan extension for Pest PHP testing framework that provides type-safe expectations,
...
Tokens:
10,372
Snippets:
138
Trust Score:
9.4
Update:
4 weeks ago
Context
Skills
Chat
Benchmark
88.7
Suggestions
Latest
Show doc for...
Code
Info
Show Results
Context Summary (auto-generated)
Raw
Copy
Link
# PestStan PestStan is a PHPStan extension specifically designed for the Pest PHP testing framework. It provides type-safe expectations, proper `$this` binding in test closures, and accurate return types for all Pest functions. The extension enables PHPStan to understand Pest's unique testing patterns, including generic type inference for the `expect()` function, type narrowing through assertions, and automatic TestCase class detection from Pest.php configuration files. Beyond type inference, PestStan includes a comprehensive suite of static analysis rules that catch common Pest testing mistakes before tests run. These rules detect issues like empty test closures, impossible expectations, redundant assertions, invalid `throws()` arguments, and improper use of lifecycle hooks. The extension requires PHP 8.2+, PHPStan 2.0+, and Pest PHP 3.0+, and integrates seamlessly with the phpstan/extension-installer for automatic registration. ## Installation Install PestStan via Composer as a development dependency. The extension auto-registers if you have phpstan/extension-installer. ```bash composer require --dev mrpunyapal/peststan ``` ## Manual Configuration For manual registration without the extension installer, add the extension to your phpstan.neon configuration file. ```neon # phpstan.neon or phpstan.neon.dist includes: - vendor/mrpunyapal/peststan/extension.neon ``` ## Generic expect() Function The extension provides generic type inference for Pest's `expect()` function, allowing PHPStan to track the exact type of the expectation value through assertion chains. ```php <?php // PHPStan knows the exact type wrapped in each Expectation expect('hello'); // Expectation<string> expect(42); // Expectation<int> expect(['a' => 1]); // Expectation<array{a: int}> expect($user); // Expectation<User> expect(); // Expectation<null> // Type information flows through assertion chains it('demonstrates type tracking', function () { $result = fetchData(); // Returns mixed expect($result) ->toBeArray() // Expectation<array> ->toHaveCount(3) // Expectation<array> ->and($result[0]) // Expectation<mixed> ->toBeString(); // Expectation<string> }); ``` ## Type Narrowing Assertions Type-checking assertion methods narrow the generic type parameter, so PHPStan tracks the refined type through assertion chains. This enables precise type checking after assertions. ```php <?php /** @var int|string $value */ $value = getValue(); it('narrows types through assertions', function () use ($value) { expect($value)->toBeString(); // PHPStan now knows the expectation wraps a string expect($value)->toBeInt(); // PHPStan now knows the expectation wraps an int }); it('narrows to specific class types', function () { $entity = fetchEntity(); // Returns Model|null expect($entity)->toBeInstanceOf(User::class); // PHPStan now knows the expectation wraps a User instance // Access User-specific methods with full type safety expect($entity->email)->toBeString(); }); // 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 across multiple values in a single expectation flow. ```php <?php it('chains multiple assertions with type safety', function () { 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}> ->and(new User) // Expectation<User> ->toBeInstanceOf(User::class); }); it('validates API response fields', function () { $response = callApi(); expect($response->status) ->toBe(200) ->and($response->body) ->toBeArray() ->and($response->body['user']) ->toBeInstanceOf(User::class) ->and($response->body['user']->email) ->toBeString() ->toContain('@'); }); ``` ## $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. ```php <?php // tests/Pest.php uses(Tests\TestCase::class)->in('Feature'); uses(Tests\UnitTestCase::class)->in('Unit'); // tests/Feature/ExampleTest.php it('can access test case methods', function () { $this->get('/'); // PHPStan knows $this is Tests\TestCase $this->assertAuthenticated(); $this->actingAs($this->user); }); beforeEach(function () { $this->assertTrue(true); // Works in hooks too $this->withoutExceptionHandling(); }); afterEach(function () { $this->assertDatabaseCount('users', 0); }); // Alternative configuration syntax with pest() pest()->extend(Tests\TestCase::class)->in('Feature'); pest()->use(Tests\HelperTrait::class)->in('Feature'); ``` ## 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 without requiring `@var` annotations. ```php <?php beforeEach(function () { $this->post = new Post; // Type: Post $this->title = 'Hello'; // Type: 'Hello' (literal string) $this->count = 42; // Type: 42 (literal int) $this->active = true; // Type: true }); it('knows the property types', function () { $this->post->title; // PHPStan knows $this->post is Post strlen($this->title); // PHPStan knows $this->title is string $this->count + 1; // PHPStan knows $this->count is int }); // For factory calls, use @var annotation on local variable beforeEach(function () { /** @var User $user */ $user = User::factory()->create(); $this->user = $user; // Type: User }); // Multiple hooks assigning same property creates union type beforeEach(function () { $this->item = new Post; }); beforeEach(function () { $this->item = new Comment; }); it('sees the union type', function () { $this->item; // Type: Post|Comment }); ``` ## Manual TestCase Override If auto-detection doesn't work for your setup, or you want a global default TestCase class, configure it manually in phpstan.neon. ```neon # phpstan.neon parameters: peststan: testCaseClass: App\Testing\TestCase # Or specify explicit Pest.php file paths if not in PHPStan paths parameters: peststan: pestConfigFiles: - tests/Pest.php - packages/core/tests/Pest.php ``` ## TestCall Chaining All `TestCall` methods return properly typed values for fluent chaining. PHPStan understands the full Pest test configuration API. ```php <?php it('does something', function () { expect(true)->toBeTrue(); }) ->with(['data-set-1', 'data-set-2']) ->group('unit', 'feature') ->skip(false) ->depends('other test') ->throws(RuntimeException::class) ->repeat(3); test('another test', function () { // test body }) ->only() ->covers(UserService::class) ->coversClass(UserRepository::class) ->group('integration'); describe('UserService', function () { beforeEach(function () { $this->service = new UserService; }); it('creates users', function () { // nested test })->group('user'); }); ``` ## not() and each() Return Types The `not()` and `each()` methods return properly typed wrapper objects that maintain type information through the expectation chain. ```php <?php it('uses opposite expectations', function () { expect('hello')->not(); // OppositeExpectation<string> expect('hello') ->not->toBeEmpty() // String is not empty ->not->toContain('x'); // String does not contain 'x' }); it('iterates over array elements', function () { expect([1, 2, 3])->each(); // EachExpectation<array{int, int, int}> expect([1, 2, 3]) ->each(fn ($number) => $number->toBeInt()) ->each->toBeGreaterThan(0); expect(['a', 'b', 'c']) ->each->toBeString() ->each->not->toBeEmpty(); }); ``` ## Architecture Testing Support Architecture testing methods are fully supported with proper type inference, enabling type-safe architectural constraints. ```php <?php arch('models extend eloquent') ->expect('App\Models') ->toExtend('Illuminate\Database\Eloquent\Model') ->ignoring('App\Models\Legacy'); arch('app classes are final') ->expect('App') ->classes() ->toBeFinal(); arch('actions are invokable') ->expect('App\Actions') ->toBeInvokable(); arch('DTOs are readonly') ->expect('App\DTOs') ->toBeReadonly(); arch('uses strict types') ->expect('App') ->toUseStrictTypes(); arch('no debugging statements') ->expect(['dd', 'dump', 'ray']) ->not->toBeUsed(); ``` ## Empty Test Closure Rule (pest.emptyTestClosure) Detects tests whose closure contains no statements. Empty test bodies are likely mistakes where assertions were forgotten. ```php <?php // VALID - todo test without closure it('does something'); // ERROR: Test 'does something' has an empty closure body. // Did you forget to add assertions? it('does something', function () {}); // VALID - test with assertions it('validates user', function () { expect($this->user)->toBeInstanceOf(User::class); }); ``` ## Static Test Closure Rule (pest.staticTestClosure) Pest binds `$this` inside every test closure to the TestCase instance. Marking the closure `static` prevents that binding and causes runtime errors. ```php <?php // ERROR: Test closure passed to it() must not be static. it('example', static function () { expect(true)->toBeTrue(); }); // ERROR: Test closure passed to test() must not be static. test('another example', static fn () => expect(1)->toBe(1)); // VALID - non-static closure it('example', function () { expect(true)->toBeTrue(); }); ``` ## Disallowed Calls in Describe Rule (pest.beforeAllInDescribe / pest.afterAllInDescribe) Pest does not support `beforeAll()` or `afterAll()` inside `describe()` blocks. Calling them throws at runtime. ```php <?php // ERROR: beforeAll() cannot be used inside describe() blocks. // ERROR: afterAll() cannot be used inside describe() blocks. describe('suite', function () { beforeAll(function () { /* ... */ }); afterAll(function () { /* ... */ }); it('test', fn () => expect(true)->toBeTrue()); }); // VALID - use beforeEach/afterEach inside describe blocks describe('suite', function () { beforeEach(function () { $this->setup = true; }); afterEach(function () { // cleanup }); it('test', fn () => expect($this->setup)->toBeTrue()); }); ``` ## Repeat Invalid Value Rule (pest.repeatInvalidValue) The `repeat()` method requires a positive integer greater than zero. ```php <?php // ERROR: repeat() requires a value greater than 0, got 0. it('runs multiple times', function () { expect(true)->toBeTrue(); })->repeat(0); // ERROR: repeat() requires a value greater than 0, got -5. it('runs multiple times', function () { expect(true)->toBeTrue(); })->repeat(-5); // VALID it('runs three times', function () { expect(true)->toBeTrue(); })->repeat(3); ``` ## Duplicate Test Description Rule (pest.duplicateTestDescription) Two tests in the same file with the same description will collide at runtime. ```php <?php // ERROR: A test with the description 'it does something' already exists in this file. it('does something', fn () => expect(1)->toBe(1)); it('does something', fn () => expect(2)->toBe(2)); // VALID - unique descriptions it('does something with integers', fn () => expect(1)->toBe(1)); it('does something with strings', fn () => expect('a')->toBe('a')); ``` ## Impossible Expectation Rule (pest.impossibleExpectation) When the static type already makes an assertion impossible, PestStan reports it. This catches bugs where tests would always fail. ```php <?php // ERROR: Calling toBeString() on Expectation<int> will always fail. expect(42)->toBeString(); // ERROR: Calling toBeNull() on Expectation<string> will always fail. expect('hello')->toBeNull(); // ERROR: Calling toBeInstanceOf() on Expectation<string> will always fail. expect('text')->toBeInstanceOf(User::class); // VALID - union type allows both possibilities /** @var int|string $value */ $value = getValue(); expect($value)->toBeString(); // OK - string is possible ``` ## Redundant Expectation Rule (pest.redundantExpectation) When the static type already guarantees an assertion will always succeed, the assertion is redundant and adds no value. ```php <?php // ERROR: Calling toBeTrue() on Expectation<true> will always pass // — the assertion is redundant. expect(true)->toBeTrue(); // ERROR: Calling toBeString() on Expectation<string> will always pass // — the assertion is redundant. expect('hello')->toBeString(); // ERROR: Calling toBeNumeric() on Expectation<int> will always pass // — the assertion is redundant. expect(42)->toBeNumeric(); // VALID - type is uncertain $config = getConfig(); // Returns mixed expect($config['timeout'])->toBeInt(); ``` ## Expectation Value Type Rule (pest.expectationRequiresIterable / pest.expectationRequiresString) Some expectation methods require the value to satisfy a pre-condition. This rule catches type mismatches. ```php <?php // ERROR: Calling each() on Expectation<int> — value is not iterable. expect(42)->each(fn ($e) => $e->toBeInt()); // ERROR: Calling toBeJson() on Expectation<int> — value must be a string. expect(42)->toBeJson(); // ERROR: Calling toStartWith() on Expectation<array> — value must be a string. expect(['a', 'b'])->toStartWith('a'); // VALID - correct types expect([1, 2, 3])->each(fn ($e) => $e->toBeInt()); expect('{"key":"value"}')->toBeJson(); expect('hello world')->toStartWith('hello'); ``` ## beforeAll $this Usage Rule (pest.beforeAllThisUsage) `beforeAll()` runs once in a static context before any tests in the file. `$this` is not available in this hook. ```php <?php // ERROR: beforeAll() runs in static context — $this is not available. // Use beforeEach() instead. beforeAll(function () { $this->db = new Database; }); // VALID - use beforeEach for $this access beforeEach(function () { $this->db = new Database; }); // VALID - beforeAll without $this beforeAll(function () { Database::migrate(); Cache::flush(); }); ``` ## Invalid throws() Exception Rule (pest.throwsClassNotFound / pest.invalidThrowsException) The `throws()` method accepts a class name that implements `Throwable`. Passing a non-existent class or a non-Throwable class is caught at analysis time. ```php <?php // ERROR: Class App\NonExistentException passed to throws() does not exist. it('fails', function () { throw new Exception('error'); })->throws('App\NonExistentException'); // ERROR: throws() expects a Throwable class, got stdClass. it('fails', function () { throw new Exception('error'); })->throws(stdClass::class); // VALID it('throws runtime exception', function () { throw new RuntimeException('error'); })->throws(RuntimeException::class); it('throws with message', function () { throw new InvalidArgumentException('Invalid input'); })->throws(InvalidArgumentException::class, 'Invalid input'); ``` ## coversClass Rule (pest.coversClassNotFound / pest.coversFunctionNotFound) `coversClass()`, `coversTrait()`, and `coversFunction()` reference symbols by name. PestStan verifies those symbols exist. ```php <?php // ERROR: Class App\Nonexistent\Service referenced in coversClass() does not exist. it('covers something', function () { // test })->coversClass('App\Nonexistent\Service'); // VALID it('covers UserService', function () { $service = new UserService; expect($service->create([]))->toBeInstanceOf(User::class); })->coversClass(UserService::class); it('covers user functions', function () { expect(formatUserName('John', 'Doe'))->toBe('John Doe'); })->coversFunction('formatUserName'); ``` ## Describe Without Tests Rule (pest.describeWithoutTests) A `describe()` block that contains no `it()` or `test()` calls is likely a mistake. ```php <?php // ERROR: describe() block 'UserService' contains no tests. describe('UserService', function () { beforeEach(fn () => null); }); // ERROR: describe() block 'Empty' contains no tests. describe('Empty', function () { // nothing here }); // VALID describe('UserService', function () { beforeEach(function () { $this->service = new UserService; }); it('creates users', function () { expect($this->service->create([]))->toBeInstanceOf(User::class); }); }); ``` ## Invalid Group Name Rule (pest.invalidGroupName) The `group()` method requires at least one non-empty, non-whitespace string argument. ```php <?php // ERROR: group() requires a non-empty string argument. it('example', fn () => null)->group(''); // ERROR: group() requires a non-empty string argument. it('example', fn () => null)->group(' '); // VALID it('example', fn () => null)->group('unit'); it('example', fn () => null)->group('unit', 'feature', 'api'); ``` ## Ignoring Rules All rules use PHPStan identifiers, so you can suppress them selectively in your baseline or inline. ```neon # phpstan.neon - ignore rules globally parameters: ignoreErrors: - identifier: pest.emptyTestClosure - identifier: pest.redundantExpectation # Or ignore specific paths parameters: ignoreErrors: - identifier: pest.impossibleExpectation path: tests/Legacy/* ``` ```php <?php // Inline suppression with @phpstan-ignore /** @phpstan-ignore pest.staticTestClosure */ it('legacy test', static fn () => expect(true)->toBeTrue()); /** @phpstan-ignore pest.redundantExpectation */ expect('hello')->toBeString(); // Intentionally redundant for documentation ``` ## Running Tests and Analysis PestStan provides composer scripts for running tests and static analysis on your test suite. ```bash # Run all checks (lint + types + unit tests) composer test # Apply code style fixes (Rector + Pint) composer lint # Check code style without making changes composer test:lint # Run PHPStan analysis only composer test:types # Run Pest unit tests only composer test:unit # Direct PHPStan command ./vendor/bin/phpstan analyse --ansi ``` PestStan enables comprehensive static analysis for Pest PHP test suites, catching type errors and common mistakes before tests run. The extension is particularly valuable for large test suites where maintaining type safety across hundreds of test files would otherwise be challenging. By integrating with PHPStan's existing analysis pipeline, it provides immediate feedback during development through IDE integrations and CI/CD pipelines. The combination of generic type inference for expectations, automatic TestCase detection, and the suite of static analysis rules makes PestStan an essential tool for teams using Pest PHP. It enforces best practices like avoiding empty test closures, preventing impossible assertions, and ensuring proper use of lifecycle hooks—all without any runtime overhead. The extension follows PHPStan conventions for error suppression, allowing teams to gradually adopt stricter rules or exclude legacy code from analysis.