wyrihaximus/async-test-utilities
Async test utilities for PHP that provide a TestCase with random namespaces and directories, fiber-based test execution with a configurable TimeOut attribute, and callable expectation helpers for ReactPHP async tests.
Install as a dev dependency (the Composer name differs from the repo name and from the badge in the README):
composer require --dev wyrihaximus/async-test-utilities
It pulls in wyrihaximus/test-utilities, react/event-loop, react/promise, react/async and PHPUnit. PHP 8.4+ and PHPUnit ^12 are required — confirm that before you add it to an older Laravel 10/11 app.
The first place to look is WyriHaximus\AsyncTestUtilities\AsyncTestCase. Extend it instead of PHPUnit\Framework\TestCase, and every test method is transparently executed inside a Fiber with a 30-second guardrail. Your first use case is almost always "I have code that returns a React\PromiseInterface or uses React\Async\await(), and a normal PHPUnit test just returns immediately with an unresolved promise." Swap the base class and the test body can now await() like production code:
use React\Promise\Timer\sleep;
use WyriHaximus\AsyncTestUtilities\AsyncTestCase;
use function React\Async\await;
final class WorkerTest extends AsyncTestCase
{
public function testJobIsConsumed(): void
{
await(sleep(0.05));
self::assertTrue(true);
}
}
Second thing to read is the TimeOut attribute (WyriHaximus\AsyncTestUtilities\TimeOut) — it is a class-level and method-level attribute, with method-level taking priority. Third are the callable expectations (expectCallableOnce(), expectCallableExactly(n)), which are the fastest way to assert that an event-loop callback fired the right number of times. Fourth are the random namespace/directory helpers inherited from test-utilities, useful when you test file- or cache-writing code without colliding across parallel test runs.
Separate the async suite. Laravel's Illuminate\Foundation\Testing\TestCase and AsyncTestCase both extend PHPUnit\Framework\TestCase, so you cannot use both in one class. Create a second testsuite rather than trying to make them coexist:
<!-- phpunit.xml -->
<testsuites>
<testsuite name="Feature"><directory>tests/Feature</directory></testsuite>
<testsuite name="Async"><directory>tests/Async</directory></testsuite>
</testsuites>
Async tests live under tests/Async and never boot the full HTTP kernel. If you genuinely need a container-bound service, bootstrap the app manually in setUp() ($app = require __DIR__.'/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap();) and keep that bootstrap outside the fiber-heavy part of the test — you want container wiring done, then the loop work inside the test method.
Assert on the resolved value, not on the promise. The fiber wrapper waits for the method to return, but it does not automatically await an un-awaited promise. Always await() the thing you care about and assert on the resolved value:
$result = await($service->handle($command));
self::assertSame('done', $result);
Use callback expectations for fire-and-forget work. Anything you register with Loop::futureTick(), Loop::addTimer() or an emitter should be paired with an expectation, otherwise a silently-dropped event still yields a green test:
$listener = $this->expectCallableExactly(3);
Loop::futureTick($listener);
Loop::futureTick($listener);
Loop::futureTick($listener);
Tune timeouts per test class, not globally. Put a tight #[TimeOut(0.3)] on the class so hung loop tests fail in a fraction of a second, then loosen individual slow tests with a method-level #[TimeOut(5)]. This is the single biggest day-to-day win over the default 30s, which will silently make CI slow.
Test async adapters, not the whole framework. The sweet spot for a Laravel codebase is ReactPHP/AMPHP infrastructure you've wrapped: a socket server, a queue consumer loop, a cache or filesystem driver that returns promises, an Octane-adjacent worker. Instantiate the class directly, drive it with promises, await() the result, and use expectOutputString() when the component writes to stdout inside a loop callback.
Treat loop ordering as part of the assertion. Loop::futureTick() callbacks queued after your await() resolves may or may not have run. If order matters, assert it explicitly in the output string or by collecting into an array:
self::expectOutputString('ab');
Loop::futureTick(async(static function (): void { echo 'a'; }));
await(sleep(0.01));
echo 'b';
wyrihaximus/makefiles is in require-dev, versions 10.0.0+ rewrite the post-install-cmd / post-update-cmd blocks of your composer.json to call make on-install-or-update || true, and will create those blocks if absent. In a Laravel app that already does @php artisan package:discover in those hooks this is a real conflict. Either don't install makefiles, or pin below 10.x, or audit composer.json after every composer update.sleep() and other blocking calls defeat the timeout. The TimeOut attribute can only fire when control returns to the event loop. A test that blocks the fiber with sleep(60) or a synchronous HTTP client will blow past its timeout and stall the whole suite. Use React\Promise\Timer\sleep() instead.Loop::run() at the end of risky tests) or make sure every timer you create is awaited/cancelled.->catch()/->then(null, ...) in the test, or assert the rejection explicitly.expectOutputString counts output from fibers, so echo inside async() closures is captured — but output from the framework's own bootstrapping or deprecations (PHPUnit itself) will also be counted and break the equality assertion. Keep these tests free of app boot noise.#[TimeOut(0.3)] — note the casing of the class (TimeOut), and that PHPUnit 12 has dropped the docblock equivalents. If you copy snippets from old blog posts using @timeout-style annotations, they are silently ignored.setUp()/tearDown(): call parent:: — the fiber wrapper and the random-directory cleanup depend on it. Skipping the parent call is the usual cause of "my temp directory survived the test run."phpunit, makefiles, test-utilities) rather than an API change. Pin a minor range (^11.0) and update deliberately; a Renovate-style bump can jump you across a PHPUnit major.wyrihaximus/test-utilities while the install line says wyrihaximus/async-test-utilities. Use the latter; the namespace is WyriHaximus\AsyncTestUtilities\.AsyncTestCase to add your own helpers (a createService() factory, a database-free fixture loader) rather than configuring the package — there is essentially no config surface. That is fine, but don't hunt for a config/async-test-utilities.php.How can I help you explore Laravel packages today?