Skip to content

feat: type a bare test() call as the bound test case - #22

Merged
MrPunyapal merged 5 commits into
pestphp:5.xfrom
ragulka:feat/type-bare-test-call
Oct 28, 2026
Merged

MrPunyapal merged 5 commits into
pestphp:5.xfrom
ragulka:feat/type-bare-test-call

Conversation

@ragulka

@ragulka ragulka commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

A bare test() call currently keeps the broad TestCall|HigherOrderTapProxy type, which hides undefined methods and loses properties assigned through test hooks.

Resolve Pest's bare test() to the bound test case in test closures, file-level helpers and static callbacks. Keep calls with a description, unbound files, and project-defined global test() functions unchanged. Helpers in bound files are inferred as running-test helpers.

Also infer hook properties assigned through test(), allow protected and bound-trait member access through the proxy, and preserve the test()->todo() helper without hiding real method typos or overriding a native todo() method.

Validation:

  • 543 tests pass locally, sequentially and with parallel workers.
  • PHPStan passes at level max.
  • Rector and Pint pass for changed PHP files.
  • Regression coverage includes helpers, static callbacks, protected and bound-trait methods, proxy/native todo calls, undefined methods, and function provenance.

All ten CI checks pass for the latest revision, covering PHP 8.4/8.5 and lowest/stable dependencies.

ragulka and others added 2 commits October 5, 2026 15:39
test() without arguments returns a proxy to the running test case, but
PHPStan only saw the declared HigherOrderTapProxy|TestCall, so method
calls through it were unchecked. Resolve it the same way $this is
resolved in test closures. Files no binding covers keep the declared
type, so helper files outside a Pest.php binding report nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hook property reader only collected `$this->name = …` assignments,
so a hook written as `test()->name = …` left the property mixed. A bare
test() proxies the running test case, so treat it as the same
receiver. Reads through test() resolve via the existing property
extension now that test() is typed as the test case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
}

// @note: a bare test() proxies the running test case, so it gets the same type as $this in a test closure; an unbound file keeps the declared type.
return $this->pestTestCaseType->resolveIfBound($scope->getFile());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be limited to test and hook closures?

Right now any bare test() call in a bound file gets the test case type, including calls inside normal helper functions. The new testBareTestCallInHelperFunction test makes that behavior explicit.

A file binding tells us which test case Pest will use, but it does not mean test() is always running in a test context. This seems broader than $this inference, which only exists inside the bound closures.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch! Indeed, $this behaves a bit different, and I agree test() should follow it.

At runtime, a bare test() returns the test case only while a test is running. Inside a helper function, the type depends on the caller, so we can't know it.

Changed in 9ac88da.

Side note: $this (and also a bare test()) is typed as the test case inside a describe() body too, although that body runs before any test does (so it can't be the test case). It was like that before this PR and likely doesn't matter in practice, so I left it alone, but I'm happy to fix it if you want.

A bare test() returns the running test case only while a test runs.
In a helper function the type depends on the caller, so it now keeps
the declared type. Inside test, hook and describe closures, and in
closures nested in them, test() gets the same type as $this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ragulka
ragulka requested a review from MrPunyapal October 7, 2026 07:12

@MrPunyapal MrPunyapal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test() doesn't branch on $this, it branches on TestSuite::getInstance()->test instanceof TestCase. So the hasVariableType('this') guard is a proxy for that and the two come apart. In a static closure there's no $this, but the test is still running, so test() is the proxy and we bail. Nothing errors, the declared union still has HigherOrderTapProxy, so the suite stays green, but test()->createHelper() on a custom test case stays unchecked.

Not sure there's a fix. A static closure has no $this to read, so there's nothing to key off. Fine to leave it open, but testBareTestCallInStaticClosureKeepsDeclaredType asserting it as correct should probably go or be inverted.

isFunctionSupported matches any function named test. Pest declares it behind if (! function_exists('test')), so a project that defines its own global test() first never gets Pest's, and this retypes it. FunctionReflection::getFileName() against pestphp/pest/src/Functions.php would be safer.

resolveIfBound() is only used as a null check, the type gets thrown away since we return $scope->getVariableType('this'). Fine today, but isBound() reads better than instanceof Type.

Two things in the description: pest is 531 passed on 9ac88da, not 529. CI hasn't run, all four workflow runs are action_required.

@odolbeau

Copy link
Copy Markdown

Thanks for this! One case it leaves out, which is where we hit the error in practice: a bare test() inside a file-level helper.

function sendWebhook(array $content): TestResponse
{
    return test()->postRawContent('/webhook', ...); // still TestCall|HigherOrderTapProxy
}

There's no $this there, so the guard bails. But in a bound test file, a helper like this only runs while a test is running. In practice the proxy is the only outcome. Could the bound test case type apply there too?

Also, once test() is typed as the test case, calls to protected methods will probably report method.protected, for example test()->assertDatabaseHas() or test()->mock(). TestCaseMemberVisibilityIgnoreExtension only accepts $this as the receiver, while at runtime the proxy reaches them through HigherOrderMessage. It may be worth accepting a bare test() receiver there as well.

@MrPunyapal MrPunyapal left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I retested the current revision locally: all 531 existing tests pass, but the earlier functional concerns remain.

Additional checks confirm that bare test() calls in file-level helpers and static closures still keep TestCall|HigherOrderTapProxy, and test()->todo() reports an undefined method on the bound test case.

Could you address these cases and add regression coverage? The earlier concern about matching a project-defined global test() also remains.

@MrPunyapal MrPunyapal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the helper/static callback inference, protected proxy access, todo helper, and function provenance concerns with regression coverage. Verified 543 tests locally, sequentially and in parallel, plus static analysis and lint. All ten CI checks pass on bfdb8d8.

@MrPunyapal
MrPunyapal merged commit d4aefe9 into pestphp:5.x Oct 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants