Testing
The bundle ships test helpers in the SomeWork\CqrsBundle\Testing namespace:
- Fake buses:
FakeCommandBus,FakeQueryBusandFakeEventBus. They implementCommandBusInterface,QueryBusInterfaceandEventBusInterfaceand record every call instead of dispatching through Messenger. - Assertions:
CqrsAssertionsTrait(or theCqrsTestCasebase class) addsassertDispatched()andassertNotDispatched(). TheDispatchedMessagePHPUnit constraint does the matching behind them.
The real CommandBus, QueryBus and EventBus classes are final. Type-hint the
interfaces in your services so a test can pass in a fake.
Requirements
The assertion helpers (CqrsAssertionsTrait, CqrsTestCase, Constraint\DispatchedMessage)
are built on PHPUnit. The bundle does not require PHPUnit itself, so your application must
install it:
The helpers support PHPUnit 11.5, 12.5 and 13: the bundle's own test suite runs on each of them (11.5 on PHP 8.2, 12.5 on PHP 8.3, 13 from PHP 8.4). The fake buses have no PHPUnit dependency.
Fake buses
| Class | Methods it records | Configuring results | Other methods |
|---|---|---|---|
FakeCommandBus |
dispatch(), dispatchSync(), dispatchAsync() |
willReturn(mixed $result): the value dispatchSync() returns (default null); willReturnFor(string $commandClass, mixed $result): the result for one command class; willThrow(Throwable $exception, ?string $commandClass = null): dispatchSync() throws it |
getDispatched(), reset() |
FakeQueryBus |
ask() |
willReturn(mixed $result): the default result; willReturnFor(string $queryClass, mixed $result): the result for one query class; willThrow(Throwable $exception, ?string $queryClass = null): ask() throws it |
getDispatched(), reset() |
FakeEventBus |
dispatch(), dispatchSync(), dispatchAsync() |
none | getDispatched(), reset() |
A fake handles nothing. It does not run the stamp pipeline or resolve dispatch modes, and it
sends nothing to a transport. The dispatch methods return new Envelope($message, $stamps),
built from the stamps you passed.
What getDispatched() returns
getDispatched() returns one SomeWork\CqrsBundle\Testing\RecordedDispatch per call, in
call order, with the read-only properties message, mode (a DispatchMode, null for
queries, which are always synchronous) and stamps (the stamps you passed). A message is
recorded before a configured exception is thrown.
The mode records the method that was called:
dispatchSync()recordsDispatchMode::SYNC.dispatchAsync()recordsDispatchMode::ASYNC.dispatch()records the mode argument you passed, orDispatchMode::DEFAULTwhen you passed none. The fake does not resolveDEFAULTthroughdispatch_modesor#[Asynchronous].
reset() clears the records. On FakeCommandBus and FakeQueryBus it also clears any
results and exceptions set with willReturn(), willReturnFor() or willThrow().
FakeCommandBus
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Command\CreateTask;
use App\Application\TaskService;
use PHPUnit\Framework\TestCase;
use SomeWork\CqrsBundle\Testing\CqrsAssertionsTrait;
use SomeWork\CqrsBundle\Testing\FakeCommandBus;
final class TaskServiceTest extends TestCase
{
use CqrsAssertionsTrait;
public function test_creating_a_task_dispatches_create_task(): void
{
$commandBus = new FakeCommandBus();
$service = new TaskService($commandBus);
$service->createTask('task-1', 'Write docs');
self::assertDispatched($commandBus, CreateTask::class);
$records = $commandBus->getDispatched();
self::assertCount(1, $records);
$message = $records[0]->message;
self::assertInstanceOf(CreateTask::class, $message);
self::assertSame('task-1', $message->id);
}
public function test_dispatch_sync_returns_the_configured_result(): void
{
$commandBus = new FakeCommandBus();
$commandBus->willReturn('task-42');
$service = new TaskService($commandBus);
// TaskService calls $commandBus->dispatchSync(...) and returns the handler result.
self::assertSame('task-42', $service->createTaskAndReturnId('Write docs'));
}
}
FakeQueryBus
willReturnFor() matches the query's exact class; subclasses do not inherit a result. It
takes precedence over willReturn(). When neither is set, ask() returns null.
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Query\FindTask;
use App\Application\Query\ListTasks;
use App\Application\TaskDashboard;
use PHPUnit\Framework\TestCase;
use SomeWork\CqrsBundle\Testing\CqrsAssertionsTrait;
use SomeWork\CqrsBundle\Testing\FakeQueryBus;
final class TaskDashboardTest extends TestCase
{
use CqrsAssertionsTrait;
public function test_dashboard_lists_tasks(): void
{
$queryBus = new FakeQueryBus();
$queryBus->willReturn([]); // default for every query
$queryBus->willReturnFor(ListTasks::class, ['task-1', 'task-2']); // result for ListTasks only
$dashboard = new TaskDashboard($queryBus);
self::assertSame(['task-1', 'task-2'], $dashboard->taskIds());
self::assertDispatched($queryBus, ListTasks::class);
self::assertNotDispatched($queryBus, FindTask::class);
}
}
FakeEventBus
The records include the stamps passed to the bus, so a test can check them too:
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Event\TaskCreated;
use App\Application\TaskService;
use PHPUnit\Framework\TestCase;
use SomeWork\CqrsBundle\Bus\DispatchMode;
use SomeWork\CqrsBundle\Stamp\IdempotencyStamp;
use SomeWork\CqrsBundle\Testing\CqrsAssertionsTrait;
use SomeWork\CqrsBundle\Testing\FakeCommandBus;
use SomeWork\CqrsBundle\Testing\FakeEventBus;
final class TaskCreatedEventTest extends TestCase
{
use CqrsAssertionsTrait;
public function test_task_created_is_published_asynchronously_with_an_idempotency_key(): void
{
$eventBus = new FakeEventBus();
$service = new TaskService(new FakeCommandBus(), $eventBus);
$service->createTask('task-1', 'Write docs');
self::assertDispatched(
$eventBus,
TaskCreated::class,
static fn (TaskCreated $event): bool => 'task-1' === $event->taskId,
);
$record = $eventBus->getDispatched()[0];
self::assertSame(DispatchMode::ASYNC, $record->mode);
self::assertInstanceOf(IdempotencyStamp::class, $record->stamps[0]);
}
}
Assertions
CqrsAssertionsTrait provides these protected static assertions:
assertDispatched(RecordsBusDispatches $bus, string $messageClass, ?callable $callback = null, string $message = ''): voidassertNotDispatched(RecordsBusDispatches $bus, string $messageClass, ?callable $callback = null, string $message = ''): voidassertStoredInOutbox()andassertNotStoredInOutbox(), with the same parameters: they only match dispatches withDispatchMode::OUTBOX, or with the default mode of a class carrying#[Outbox](see Code that stores messages in the outbox).
Their parameters work as follows:
$busis any object that implementsRecordsBusDispatches. The three fakes do, and your own test doubles can too.- A record matches when its message is an
instanceof $messageClass, so subclasses and interfaces match as well. $callbackreceives the message and returnstruefor a match.assertDispatched()passes when at least one recorded message matches both the class and the callback.assertNotDispatched()passes when none does.$messageis the custom failure message. It is the fourth parameter, so pass it by name when you have no callback. A message string passed third lands in$callbackand causes aTypeError.
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Command\CreateTask;
use App\Application\Command\DeleteTask;
use SomeWork\CqrsBundle\Testing\CqrsTestCase;
use SomeWork\CqrsBundle\Testing\FakeCommandBus;
final class AssertionExamplesTest extends CqrsTestCase
{
public function test_examples(): void
{
$bus = new FakeCommandBus();
$bus->dispatch(new CreateTask('task-1', 'Write docs'));
self::assertDispatched($bus, CreateTask::class);
self::assertDispatched($bus, CreateTask::class, static fn (CreateTask $c): bool => 'Write docs' === $c->name);
self::assertDispatched($bus, CreateTask::class, message: 'CreateTask must be dispatched after the form is submitted');
self::assertNotDispatched($bus, DeleteTask::class);
}
}
On failure, the message names the classes that were actually dispatched, with
the mode each dispatch was recorded with
(Actually dispatched: App\Application\Command\CreateTask (DispatchMode::DEFAULT)), or says
No messages were dispatched.
CqrsTestCase or CqrsAssertionsTrait
CqrsTestCase is an abstract PHPUnit\Framework\TestCase that uses the trait. Extend it
for plain unit tests. When your test already extends KernelTestCase, WebTestCase or
another base class, use the trait instead.
The trait also adds resetCqrsState(), which is marked #[Before]. It clears the bundle's
internal message-type resolution cache before each test. That cache is keyed by service
locator, so separate containers do not share entries. The reset is only a safety net, and
you never need to call it yourself.
Using the constraint directly
Constraint\DispatchedMessage takes (string $expectedClass, ?callable $callback = null, ?DispatchMode $mode = null);
with a mode, it only matches dispatches with that mode (new DispatchedMessage(OrderPlaced::class, null, DispatchMode::OUTBOX)
is what assertStoredInOutbox() uses). You can combine it with PHPUnit's logical constraints:
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Command\CreateTask;
use App\Application\Command\DeleteTask;
use PHPUnit\Framework\Constraint\LogicalNot;
use PHPUnit\Framework\Constraint\LogicalOr;
use PHPUnit\Framework\TestCase;
use SomeWork\CqrsBundle\Testing\Constraint\DispatchedMessage;
use SomeWork\CqrsBundle\Testing\FakeCommandBus;
final class ConstraintExamplesTest extends TestCase
{
public function test_constraint(): void
{
$bus = new FakeCommandBus();
$bus->dispatch(new CreateTask('task-1', 'Write docs'));
self::assertThat($bus, new DispatchedMessage(CreateTask::class));
self::assertThat($bus, new LogicalNot(new DispatchedMessage(DeleteTask::class)));
self::assertThat($bus, LogicalOr::fromConstraints(
new DispatchedMessage(CreateTask::class),
new DispatchedMessage(DeleteTask::class),
));
}
}
Testing handlers directly
Handlers are services with a typed __invoke() method. To test one, create it with real or
in-memory dependencies and call it with a message. No bus is involved:
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Application\Command\CreateTask;
use App\Application\Command\CreateTaskHandler;
use App\Application\Query\FindTask;
use App\Application\Query\FindTaskHandler;
use App\Tests\Double\InMemoryTaskRepository;
use PHPUnit\Framework\TestCase;
final class TaskHandlersTest extends TestCase
{
public function test_create_then_find(): void
{
$repository = new InMemoryTaskRepository();
(new CreateTaskHandler($repository))(new CreateTask('task-1', 'Write docs'));
$task = (new FindTaskHandler($repository))(new FindTask('task-1'));
self::assertSame('Write docs', $task->name);
}
}
When you call an EnvelopeAware handler directly and it reads $this->getEnvelope(),
first pass an envelope with $handler->setEnvelope(new Envelope($message)).
Swapping the buses for fakes in the test container
Kernel and web tests use the real services. To make everything that depends on
CommandBusInterface, QueryBusInterface or EventBusInterface receive a fake, redefine
the interface aliases for the test environment. Use either config/services_test.yaml or
a when@test block in config/services.yaml:
# config/services.yaml
when@test:
services:
SomeWork\CqrsBundle\Testing\FakeCommandBus:
public: true
SomeWork\CqrsBundle\Testing\FakeQueryBus:
public: true
SomeWork\CqrsBundle\Testing\FakeEventBus:
public: true
SomeWork\CqrsBundle\Contract\CommandBusInterface:
alias: SomeWork\CqrsBundle\Testing\FakeCommandBus
public: true
SomeWork\CqrsBundle\Contract\QueryBusInterface:
alias: SomeWork\CqrsBundle\Testing\FakeQueryBus
public: true
SomeWork\CqrsBundle\Contract\EventBusInterface:
alias: SomeWork\CqrsBundle\Testing\FakeEventBus
public: true
Application configuration overrides the aliases the bundle registers. Every service that type-hints an interface then gets the fake. Fetch the same instance from the test container to configure it and assert on it:
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Application\Command\CreateTask;
use App\Application\Query\FindTask;
use SomeWork\CqrsBundle\Testing\CqrsAssertionsTrait;
use SomeWork\CqrsBundle\Testing\FakeCommandBus;
use SomeWork\CqrsBundle\Testing\FakeQueryBus;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class TaskControllerTest extends WebTestCase
{
use CqrsAssertionsTrait;
public function test_post_dispatches_create_task(): void
{
$client = static::createClient();
$client->request('POST', '/tasks', server: ['CONTENT_TYPE' => 'application/json'], content: '{"name":"Write docs"}');
self::assertResponseStatusCodeSame(201);
self::assertDispatched(
static::getContainer()->get(FakeCommandBus::class),
CreateTask::class,
static fn (CreateTask $command): bool => 'Write docs' === $command->name,
);
}
public function test_get_renders_the_query_result(): void
{
$client = static::createClient();
static::getContainer()->get(FakeQueryBus::class)->willReturnFor(FindTask::class, ['id' => 'task-1', 'name' => 'Write docs']);
$client->request('GET', '/tasks/task-1');
self::assertResponseIsSuccessful();
}
}
The fakes are ordinary shared services, so each freshly booted kernel gets new, empty
instances. The test client reboots the kernel before each request after the first one, so
a fake configured before the second request would be lost: call $client->disableReboot()
when a test sends several requests, and configure the fakes after createClient(). Only the interface aliases change. The concrete SomeWork\CqrsBundle\Bus\CommandBus,
QueryBus and EventBus services stay registered and public, and services that type-hint
those classes still get the real buses.
Kernel tests through the real buses
To test the whole flow (stamp pipeline, Messenger middleware, handlers), boot the kernel and fetch the buses from the container:
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Application\Command\CreateTask;
use App\Application\Query\FindTask;
use SomeWork\CqrsBundle\Bus\CommandBus;
use SomeWork\CqrsBundle\Bus\QueryBus;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class TaskFlowTest extends KernelTestCase
{
public function test_a_created_task_can_be_found(): void
{
self::bootKernel();
$container = static::getContainer();
// The concrete services are the real buses even when the interfaces point to fakes.
$commandBus = $container->get(CommandBus::class);
$queryBus = $container->get(QueryBus::class);
$commandBus->dispatchSync(new CreateTask('task-1', 'Write docs'));
$task = $queryBus->ask(new FindTask('task-1'));
self::assertSame('Write docs', $task->name);
}
}
If you did not swap in fakes, fetching CommandBusInterface::class and QueryBusInterface::class
works as well, because both aliases are public.
dispatchSync() and ask() return the handler result. When the single handler throws,
they rethrow its exception unwrapped, so expectException(TaskNotFound::class) works
directly. Messages that the dispatch mode or routing sends to an asynchronous transport are
not handled during the test. In the test environment, point those transports at Messenger's
in-memory transport and assert on what was sent:
# config/packages/messenger.yaml
when@test:
framework:
messenger:
transports:
# serialize=true encodes and decodes each message, as a real transport does,
# so a message that cannot be serialized fails the test instead of production.
async: 'in-memory://?serialize=true'
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Application\Event\TaskCreated;
use SomeWork\CqrsBundle\Bus\EventBus;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class AsyncEventTest extends KernelTestCase
{
public function test_task_created_goes_to_the_async_transport(): void
{
self::bootKernel();
$container = static::getContainer();
// TaskCreated is routed to the "async" transport (Messenger routing or somework_cqrs.transports).
$container->get(EventBus::class)->dispatchAsync(new TaskCreated('task-1', 'Write docs'));
$sent = $container->get('messenger.transport.async')->getSent();
self::assertCount(1, $sent);
self::assertInstanceOf(TaskCreated::class, $sent[0]->getMessage());
}
}
To assert on what went through the real buses without a transport, use Messenger's
profiler integration: with the profiler enabled in the test environment
(framework.profiler.enabled: true) each Messenger bus is decorated by a
TraceableMessageBus. While collecting (framework.profiler.collect: true, or
$client->enableProfiler() before a request), getDispatchedMessages() on the bus service
(messenger.default_bus, or an id you configured under somework_cqrs.buses, such as
event.bus) lists each dispatched message with its stamps.
To handle what was sent to an in-memory transport, run a worker in the test, for example
the messenger:consume async --limit=1 --time-limit=5 command through CommandTester
(the time limit keeps an empty transport from hanging the test; it does not fail it, so
assert on what the handler did).
dispatchAsync() requires an async bus (somework_cqrs.buses.event_async for events,
command_async for commands). Without one, the bus throws
AsyncBusNotConfiguredException.
Code that stores messages in the outbox
Code that stores messages through the buses is tested with the fake buses. For the
PlaceOrderHandler of Through the buses, which dispatches the
#[Outbox] event OrderPlaced with the default mode:
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$connection->executeStatement('CREATE TABLE orders (id VARCHAR(36) NOT NULL)');
$eventBus = new FakeEventBus();
(new PlaceOrderHandler($connection, $eventBus))(new PlaceOrder('order-1'));
self::assertStoredInOutbox($eventBus, OrderPlaced::class, static fn (OrderPlaced $event): bool => 'order-1' === $event->orderId);
The assertion matches a dispatch with DispatchMode::OUTBOX, or with the default mode of a
class carrying #[Outbox], and the fakes return an envelope with an OutboxStoredStamp (with a
generated id) for it. A fake bus does not know the configuration: a dispatch() with the default
mode that dispatch_modes sends to the outbox is recorded as DispatchMode::DEFAULT, so check it
with assertDispatched(), or test the resolution in a kernel test as below. The failure message
lists each recorded dispatch with its mode.
Code that calls OutboxWriter::store() itself type-hints Contract\Outbox\OutboxWriterInterface
(the container autowires it to the writer), and unit tests pass a Testing\FakeOutboxWriter.
It records each stored message with DispatchMode::OUTBOX (and the transport given to store()
as a TransportNamesStamp), so the outbox assertions work on it too:
$writer = new FakeOutboxWriter();
(new ExportOrderHandler($writer))(new ExportOrder('order-1'));
self::assertStoredInOutbox($writer, OrderExported::class, static fn (OrderExported $event): bool => 'order-1' === $event->orderId);
getStoredRows() returns the rows store() returned (encoded with PHP's serializer), and
willThrow() makes store() fail, e.g. with OutboxRequiresTransactionException. The fake does
not resolve the configured transports and checks no transaction.
To test the outbox itself, run the code against a real outbox table. The outbox stores its rows
in the transaction of the code under test, so it uses the same connection: in the test
environment, point that connection at a test database (an SQLite file or in-memory database is
enough), and create the table before the code under test opens its transaction (the automatic
setup never runs inside one). An outbox on a connection of its own only works when the code
under test opens its transaction on that connection. Then read the stored rows through the OutboxStorage
service, or relay them to an in-memory transport:
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Application\Command\PlaceOrder;
use App\Domain\Event\OrderPlaced;
use SomeWork\CqrsBundle\Bus\CommandBus;
use SomeWork\CqrsBundle\Contract\Outbox\OutboxSchema;
use SomeWork\CqrsBundle\Contract\Outbox\OutboxStorage;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class PlaceOrderOutboxTest extends KernelTestCase
{
public function test_the_order_placed_event_is_stored_then_relayed(): void
{
self::bootKernel();
$container = static::getContainer();
$container->get(OutboxSchema::class)->setup();
$container->get(CommandBus::class)->dispatchSync(new PlaceOrder('order-1'));
// Each row records the message class in its "type" header.
$rows = $container->get(OutboxStorage::class)->fetchUnpublished(10);
self::assertCount(1, $rows);
self::assertSame(OrderPlaced::class, json_decode($rows[0]->headers, true)['type']);
$relay = new CommandTester((new Application(self::$kernel))->find('somework:cqrs:outbox:relay'));
self::assertSame(0, $relay->execute([]));
self::assertCount(1, $container->get('messenger.transport.async')->getSent());
}
}
The relay resets the services after each row it handles in the test's process (no transport,
sync://), which also empties the in-memory transports: pass '--no-reset' => true when the
test relays such rows and then asserts on getSent().
To test middleware that skips the relay's dispatch, build the envelope as the relay dispatches it:
new Envelope($message, [new RelayedFromOutboxStamp()]).
Tips
- Type-hint the interfaces (
CommandBusInterface,QueryBusInterface,EventBusInterface) in your services. Unit tests can then pass a fake, and the test container can swap the aliases. - Unit-test handlers without buses. Call
__invoke()directly. Use the fakes to test the code that dispatches. - Check message properties with the
assertDispatched()callback, or readgetDispatched()[n]->message. - Check the dispatch mode through the
modeentry of a record, not by relying ondispatch_modesconfiguration: the fakes record the mode the caller passed.