heyjordanparker / mago-pest
Mago analyzer support for Pest: $this in test closures, dynamic test properties, and expectation chains.
Requires
- php: ^8.3
- carthage-software/mago: ^1.50
Requires (Dev)
- pestphp/pest: ^4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Mago analyzer extension that types Pest test files the way Pest runs them.
Without it, Mago reads a Pest test file as plain PHP. It cannot see the test case class that $this refers to inside a test closure, so every call to a test case method or helper trait, and every property a beforeEach hook sets, is reported. Expectation chains are reported too, because Pest dispatches most of them through __call and __get.
With it, Mago analyzes:
$thisintest(),it(),beforeEach(), andafterEach()closures, and in thebeforeEach()andafterEach()hooks ofpest()anduses(), as the test case Pest builds frompest()->extend(),pest()->use(),uses(), and theirin()targets.- Properties that those closures assign to
$this, typed by the assigned values. - Matcher chains:
expect($value)->toBe(1)->not->toBeNull()->each->toBeInt(). - Higher-order expectations:
expect($user)->name->toBe('Ada')andexpect($user)->domain()->toBe('example.com'). - Custom matchers from
expect()->extend('name', closure), with the closure's parameters. toThrow()with a closure typed by the exception it expects.
Install
composer require --dev carthage-software/mago heyjordanparker/mago-pest
The project owns its worker entrypoint. Create .mago/extensions.php:
<?php declare(strict_types=1); use Mago\Sdk\Worker; use MagoPest\PestExtension; require dirname(__DIR__) . '/vendor/autoload.php'; (new Worker(PestExtension::create()))->run();
Register it in mago.toml:
[extension-hosts.pest] command = ["php", ".mago/extensions.php"]
PestExtension::create() takes one option, testDirectory. It is Pest's test directory relative to the Mago workspace, and it defaults to tests:
(new Worker(PestExtension::create(testDirectory: 'src/Tests')))->run();
A worker can host several extensions:
(new Worker(App\Mago\Extension::create(), PestExtension::create()))->run();
How it works
Before Mago parses the project, the extension reads every PHP file under the test directory. For each test file it declares the class Pest generates, P\<path>, with the same parent class and traits Pest gives it. Each pest() or uses() declaration with hooks gets a class of its own, and test files share the properties those hooks assign.
Pest marks its generated class #[AllowDynamicProperties]. Mago does not model that attribute, so the declared class has __get and __set instead. Mago therefore reports a property that no closure assigns as non-documented-property, not non-existent-property.
A property's type is the union of every value assigned to it. The extension reads each value from its tokens, so it can type only a value whose tokens state the type:
- a literal, a string, an array, or a cast
newof a named class- a closure, typed by its own signature
- one call to a function, a static method, or a method on
$this, typed by its declared return type
Any other value is mixed. That includes a variable, an array access, a chained call, an arithmetic expression, and a return type that depends on templates. A property assigned any mixed value is mixed.
Limits
- Closures in a
describe()body outside a test or hook do not run on a test case, and Pest does not bind$thisin them. Mago reports$thisthere, as it should. Closure::bind()andClosure::call()rebind$thisat runtime, and the extension does not follow them.- A
pest()oruses()declaration is read only when its classes are given asName::classor as a string, and itsin()targets as strings or__DIR__.
Development
composer install just check
just check validates composer.json, then checks formatting, lints, analyzes the source, and runs the corpus. The corpus in tests/corpus runs the real worker over Pest test files and checks every inline @mago-expect annotation. A test in it that passes shows a capability, and an annotated line shows a defect the extension still reports.
License
MIT