matthiasnoback/phpunit-asynchronicity
PHPUnit/Behat helper for testing asynchronous behavior. Provides assertEventually() to retry a callable until assertions pass or a timeout occurs—useful for waiting on files, processes, or UI updates, with configurable timeout and polling interval.
Installation Add the package via Composer:
composer require --dev matthiasnoback/phpunit-asynchronicity
Requires PHPUnit 9.5+ and PHP 8.1+.
First Use Case Test an async queue job or event listener:
use MatthiasNoback\PHPUnitAsynchronicity\AsyncTestTrait;
class AsyncJobTest extends TestCase
{
use AsyncTestTrait;
public function testJobProcessesAsync()
{
$this->dispatchSync(new ProcessOrderJob(123));
$this->assertEventuallyTrue(fn() => Order::find(123)->processed_at !== null);
}
}
Key Files
AsyncTestTrait.php (core assertions)AsyncTestCase.php (pre-configured base class)README.md (examples + edge cases)// Test a delayed notification
$this->assertEventuallyTrue(
fn() => User::find(1)->notified_at !== null,
1000, // timeout (ms)
100 // interval (ms)
);
// Dispatch and verify processing
$this->dispatchSync(new SendEmailJob($user));
$this->assertEventually(
fn() => Mail::assertSent(SendWelcomeEmail::class),
5000
);
// Verify async event handling
event(new OrderPlaced($order));
$this->assertEventuallyTrue(
fn() => $order->refresh()->status === 'shipped'
);
// Override default queue connection for tests
protected function getAsyncTestConfiguration(): AsyncTestConfiguration
{
return AsyncTestConfiguration::create()
->withQueueConnection('test_queue');
}
assertEventually for non-deterministic async operations.assertEventuallyTrue/False for boolean checks.Flaky Tests
$this->assertEventually(
fn() => $this->app->make(Logger::class)->hasErrors(),
15000, // 15s timeout
500 // 500ms interval
);
Queue Worker Not Running
php artisan queue:work --daemon or mock the queue.dispatchSync() for critical paths in tests.State Pollution
tearDown() or use transactions:
public function setUp(): void
{
parent::setUp();
$this->beginDatabaseTransaction();
}
Timeout Too Short
TimeoutException.$this->assertEventually(/* ... */, 30000); // 30s timeout
$this->assertEventually(
fn() => tap($this->getAsyncState(), fn($state) => $this->info($state)),
10000
);
$this->assertEventuallyTrue(
fn() => Queue::size('default') === 0
);
assertEventuallyMatches() for complex conditions:
$this->assertEventuallyMatches(
fn() => Order::find(123),
fn(Order $order) => $order->status === 'completed' && $order->processed_at > now()->subMinute()
);
Custom Assertions
Extend AsyncTestTrait to add domain-specific assertions:
trait MyAsyncAssertions
{
protected function assertOrderShipped(int $orderId): void
{
$this->assertEventuallyTrue(
fn() => Order::find($orderId)->status === 'shipped'
);
}
}
Override Configuration Customize timeouts/intervals per test class:
class SlowAsyncTest extends AsyncTestCase
{
protected function getAsyncTestConfiguration(): AsyncTestConfiguration
{
return parent::getAsyncTestConfiguration()
->withTimeout(60000) // 60s
->withInterval(2000); // 2s
}
}
Mock Async Operations For unit tests, bypass async entirely:
$this->partialMockBuilder(Queue::class)
->disableOriginalConstructor()
->disableOriginalClone()
->setMethods(['push'])
->getMock()
->expects($this->once())
->method('push')
->with($this->equalTo(new ProcessOrderJob(123)));
How can I help you explore Laravel packages today?