zenstruck/messenger-test
TestTransport for symfony/messenger that intercepts queued messages so you can inspect and assert against them in your tests. Sent messages are serialized and unserialized as a check, and the transport can also process queued messages. Use the InteractsWithMessenger trait to assert queue state, mess...
Install the package as a dev dependency:
composer require --dev zenstruck/messenger-test
Symfony Flex usually registers Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle under test: true in config/bundles.php automatically. If it doesn't, add it manually:
// config/bundles.php
Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle::class => ['test' => true],
Then point every transport you want to inspect at the test:// DSN only in the test environment:
# config/packages/messenger.yaml
when@test:
framework:
messenger:
transports:
async: test://
Your first test is usually a "did this controller/service dispatch the right message?" assertion:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Messenger\Test\InteractsWithMessenger;
class RegisterUserTest extends KernelTestCase
{
use InteractsWithMessenger;
public function test_it_queues_a_welcome_email(): void
{
// ...run the code that dispatches the message
$this->transport()->queue()->assertCount(1);
$this->transport()->queue()->assertContains(SendWelcomeEmail::class);
}
}
The three classes worth reading first are InteractsWithMessenger (test entry point), Transport\TestTransport (the DSN transport and processing controls), and EnvelopeCollection/TestEnvelope (the assertion API you'll use in almost every test).
The default behaviour is interception: messages routed to a test:// transport are serialized/unserialized and parked in an in-memory queue rather than being handled. That means your first layer of tests can be pure "dispatch contract" tests:
$this->transport()->queue()->assertEmpty();
$this->transport()->queue()->assertCount(3);
$this->transport()->queue()->assertContains(MyMessage::class);
$this->transport()->queue()->assertContains(MyMessage::class, 3);
$this->transport()->queue()->assertNotContains(MyMessage::class);
Because messages are round-tripped through the serializer, this also acts as a cheap serializability test: a message with a non-serializable property (a closure, a resource, an entity graph) will fail here, before it fails in production.
If you only care whether something was dispatched and not about the transport boundary, the README notes you can assert on the bus itself instead; use the transport when routing/serialization is part of the behaviour you're testing.
This is where the package earns its keep versus a simple Queue::fake()-style swap. You can advance the queue and assert on outcomes:
$this->transport()->queue()->assertCount(3);
$this->transport()->process(1);
$this->transport()->queue()->assertCount(2);
$this->transport()->process(); // drain everything (including messages created while handling)
$this->transport()->queue()->assertEmpty();
$this->transport()->acknowledged()->assertCount(3);
$this->transport()->rejected()->assertEmpty();
Use processOrFail() when an empty queue should be a test failure rather than a no-op — useful for guarding against "the message was never actually queued" false positives.
queue(), dispatched(), acknowledged(), and rejected() all return an EnvelopeCollection, so assertions you learn once apply everywhere. Filtering helpers are the most useful day-to-day:
$messages = $this->transport()->queue()->messages(MyMessage::class); // MyMessage[]
$envelope = $this->transport()->queue()->first(MyMessage::class);
$envelope->assertHasStamp(DelayStamp::class);
// Callback filtering, with the message class as the typehint:
$this->transport()
->acknowledged()
->first(fn(MyMessage $m): bool => $m->isPriority())
->assertHasStamp(SomeStamp::class);
first() returns a TestEnvelope, an Envelope decorator — all normal Envelope methods (getMessage(), last(), all()) keep working, plus the stamp assertions.
Instead of repeating $this->transport() and risking brittle variable juggling, chain and use back():
$this->transport()
->queue()
->assertNotEmpty()
->assertContains(MyMessage::class)
->back()
->dispatched()
->assertEmpty()
->back()
->acknowledged()
->assertEmpty();
This reads well in code review and mirrors the "assert the state at each step" style Symfony developers expect from WebTestCase crawlers.
You don't need the full HTTP/bus flow to test a worker path — put a message on the transport yourself:
$this->transport()->send(new MyMessage('payload'));
$this->transport()->send(Envelope::wrap(new MyMessage(), [new SomeStamp()]));
$this->transport()->process();
$this->transport()->acknowledged()->assertContains(MyMessage::class);
This is the closest analogue to "arrange a job in the queue, then run the worker" and is often faster and more focused than dispatching through a service just to reach the queue.
When interception gets in the way — e.g. you want a WebTestCase request to fully complete including handlers — switch off the block:
$this->transport()->unblock();
// ...make the request; messages are handled immediately
$this->transport()->intercept();
Calling unblock() when messages are already queued processes them first. You can make this the default per transport:
async: test://?intercept=false
Give each transport its own test:// DSN and address them by name:
$this->transport('high')->queue()->assertContains(HighPriorityMessage::class);
$this->transport('low')->acknowledged()->assertEmpty();
Configuration knobs live in the DSN query string, which is convenient because they can be overridden per test environment:
test://?catch_exceptions=false — let handler exceptions bubble instead of being captured as rejections.test://?intercept=false — process messages immediately as they're sent.test://?test_serialization=false — skip the serialize/unserialize round-trip for speed.test://?support_delay_stamp=true — respect DelayStamp (requires a PSR-20 clock; Symfony's symfony/clock is auto-wired).If you opt into delay support, pair it with ClockSensitiveTrait so you can simulate the passage of time without sleeping:
$clock = self::mockTime();
$transport = $this->transport('async');
$transport->process();
$transport->queue()->assertCount(2); // both messages are still delayed
$transport->acknowledged()->assertCount(0);
$clock->sleep(60 * 60 * 24); // advance one day
$transport->process()->acknowledged()->assertContains(TakeSomeAction1::class);
$transport->queue()->assertCount(1);
The transport accumulates state. Reset in setUp() to avoid order-dependent tests, or use TestTransport::resetAll() when a test exercises several transports:
protected function setUp(): void
{
self::bootKernel();
TestTransport::resetAll();
}
when@test block is not optional. If you forget it, the transport isn't a test:// DSN and none of these helpers see anything. A "queue is always empty" failure is almost always a YAML environment problem, not a code problem.queue(). When debugging an empty queue, check messenger.yaml routing before re-reading your assertions. This is the main conceptual difference from Laravel's Queue::fake(), which swaps the implementation wholesale; here the real Messenger pipeline — routing, stamps, serialization — still runs.rejected(). That's great for asserting failure paths, terrible for debugging a broken handler — you'll see a green test with a rejection you didn't assert on. Call $this->transport()->throwExceptions() (or set catch_exceptions=false in the DSN) while investigating.process() is not just "run one message". It also processes messages created during handling, up to the optional $number. This is usually what you want for cascade scenarios, but don't assume process(1) leaves exactly one item consumed if your handler dispatches more work. Assert on queue()->count() to confirm actual behaviour.intercept() vs unblock() changes the meaning of queue(). In unblock mode the queue is drained as you go, so queue assertions become meaningless. Pick one style per test.sleep() on the clock and then call process() explicitly.assertHasStamp(DelayStamp::class) works), but timing isn't honoured unless you enable support_delay_stamp. A missing PSR-20 clock implementation will surface as a container error rather than a test assertion failure.first() needs a non-empty collection. Guard with assertNotEmpty() or assertCount() first if the collection might legitimately be empty; otherwise you get a "no envelope found" failure that masks the real cause.assertContains(MyMessage::class, 0) is valid and equivalent to assertNotContains(), which is handy in data providers but easy to misread in review. Prefer the explicit form.TestEnvelope is a decorator, not a subclass you should type-hint against in application code. Keep production code on Envelope; the test helpers stay in tests.TestTransport::resetAll() in setUp() is cheap insurance.test_serialization globally just to make one awkward message pass — fix the message.TestTransport/EnvelopeCollection/TestEnvelope trio. If you need repeated assertion helpers (e.g. "assert these three messages were queued in this order"), wrap EnvelopeCollection in your own assertion trait rather than scattering raw messages() calls through your suite.DelayStamp support landed in 1.8.0. If delay-related options appear to do nothing, check the installed version before assuming a config error.How can I help you explore Laravel packages today?