Skip to content

Support Pest test framework (suppress InvalidScope on $this in closures, allow internal Pest API calls) #966

@alies-dev

Description

@alies-dev

Problem

When analyzing a Laravel project that uses Pest for testing, psalm/plugin-laravel emits errors that are false positives in the Pest DSL. Pest test files are not classes. The framework rewrites them into anonymous classes at runtime, and the DSL exposes helpers (expect(), uses(), test(), it()) that internally call methods marked @internal to the Pest package.

Two error classes dominate:

1. InvalidScope$this inside test() / it() closures

Pest binds the closure passed to test() / it() to the underlying test case, so $this is valid at runtime. Psalm sees a closure in the root namespace and reports:

ERROR: InvalidScope
Invalid reference to $this in a non-class context (see https://psalm.dev/013)
    $this

Minimal repro:

// tests/Feature/ExampleTest.php
test('user can do a thing', function (): void {
    $this->get('/');
});

2. InternalMethod — calls to Pest\PendingCalls\* and Pest\Mixins\Expectation

The Pest DSL is implemented via methods on Pest\PendingCalls\TestCall, Pest\PendingCalls\UsesCall, and Pest\Mixins\Expectation. These are tagged @internal, but the public Pest API (uses()->in(...), test(...)->with(...), expect(...)->toBe(...), ->toContain(...)) is the intended way to call them.

ERROR: InternalMethod
The method Pest\PendingCalls\UsesCall::in is internal to Pest but called from root namespace (see https://psalm.dev/175)
uses(TestCase::class)->in('Unit', 'Feature');

ERROR: InternalMethod
The method Pest\PendingCalls\TestCall::with is internal to Pest but called from root namespace (see https://psalm.dev/175)
})->with([...]);

ERROR: InternalMethod
The method Pest\Mixins\Expectation::toContain is internal to Pest but called from root namespace
    expect($output)->toContain('...');

ERROR: InternalMethod
The method Pest\Mixins\Expectation::toBe is internal to Pest but called from root namespace
    expect($count)->toBe(1);

Environment

  • psalm/plugin-laravel: dev-master
  • vimeo/psalm: 7.0.0-beta19
  • pestphp/pest: v4.7.0
  • PHP: 8.4

Proposal

Add first-class Pest awareness, similar to how psalm/phpunit-plugin handles PHPUnit. Two pieces:

A. Resolve $this inside Pest test closures

When a closure is passed as the second argument to test(), it(), describe(), beforeEach(), afterEach(), beforeAll(), afterAll(), treat its body as bound to the relevant Tests\TestCase (or the class registered via uses()->in(...) for that path). This eliminates the InvalidScope error and unlocks accurate type info for $this->get(...), $this->assertX(...), Laravel test traits, etc.

The mapping of paths to base classes can come from parsing tests/Pest.php's uses(...)->in(...) calls, falling back to Tests\TestCase if present.

B. Treat the Pest DSL surface as public

Suppress InternalMethod for calls into:

  • Pest\PendingCalls\TestCall (e.g. ->with, ->group, ->skip, ->throws, ->depends)
  • Pest\PendingCalls\UsesCall (e.g. ->in, ->group)
  • Pest\PendingCalls\BeforeEachCall / AfterEachCall
  • Pest\Mixins\Expectation (all toX expectations, not, and, each, sequence, etc.)
  • Pest\Mixins\HigherOrderExpectation

…when invoked from a file under the configured Pest test directories (or from any file that calls uses(...), which marks it as a Pest test).

Discoverability

Following the spirit of #788, psalm-laravel init could detect pestphp/pest in composer.json and either enable this behavior automatically or print a hint pointing to it.

Workarounds today

Users currently have to either:

  • Exclude the entire tests/ directory from Psalm, losing all coverage of test code.
  • Litter test files with @psalm-suppress InvalidScope and @psalm-suppress InternalMethod.
  • Use a separate psalm-pest config with errorLevel="suppress" for these issues, which also masks real bugs.

A dedicated Pest integration in psalm/plugin-laravel (or a sibling psalm/pest-plugin it auto-suggests) would let Laravel + Pest users get real Psalm coverage on their test suite.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions