Testing DDD applications with Behat and in-memory adapters
A practical testing architecture for combining fast domain tests, executable Behat application specifications, in-memory ports, deterministic time, real Symfony boundaries, and focused infrastructure tests.
Why reading the system clock inside business logic creates nondeterministic behavior, and how an explicit Clock port makes expiry, validity, scheduling, persistence, and time-sensitive tests predictable.
How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.
Using named domain exceptions to express rejected business rules, roll back commands, cross Symfony Messenger safely, produce RFC 9457 problem responses, and separate expected failures from real incidents.
Read
Need a software engineer?
Send the goal, stack, and timeline. I'll reply with a next step.
A suite of green unit tests can still leave the application broken. Each class may work alone while the command bus, policies, persistence, and events fail to collaborate.
The password policy works. The aggregate changes state. The repository mock receives save(). Every class appears correct in isolation.
Then a real request reaches the application and discovers that the command is wired to the wrong handler, the handler forgot to call the policy, a test double behaves differently from the production repository, or a domain event never reaches the application boundary.
This is one of the hardest testing problems in a Domain-Driven Design application: how do I test complete business behavior without making every test boot HTTP, PostgreSQL, Redis, S3, and half the internet?
While building a Symfony platform organized into bounded contexts, I separated tests by the question they answer:
PHPUnit unit tests prove individual domain rules and state transitions.
Behat application specifications execute real commands and queries through the real application buses.
In-memory adapters replace persistence and external infrastructure without replacing the use case.
Package contract tests protect the frontend-facing API schemas and request builders.
As a co-founder, I also want the test suite to preserve product knowledge. A scenario such as "a user covered by an organization cannot buy another subscription" is far more useful to a new engineer than a test called testHandleReturnsFalse().
This guide applies that testing structure to a generic Symfony and DDD project.
The classic unit test for a password policy is useful:
PHP
finalclassPasswordPolicyTestextendsTestCase{#[DataProvider('weakPasswords')]publicfunctiontestItRejectsWeakPasswords(string$password,string$expectedErrorId,):void{try{$this->policy()->ensureSatisfiedBy(newPlainPassword($password));}catch(WeakPassword$error){self::assertSame($expectedErrorId,$error->id());return;}self::fail('Weak password was accepted.');}publicstaticfunctionweakPasswords():iterable{yield'too short'=>['Ab1!','identity.exceptions.weak_password.too_short',];yield'missing uppercase'=>['password1!','identity.exceptions.weak_password.missing_uppercase',];yield'missing number'=>['Password!','identity.exceptions.weak_password.missing_number',];}}
It is fast, deterministic, and documents the policy matrix precisely.
But it does not prove that registration calls PasswordPolicy. It does not prove that the command handler uses the submitted password. It does not prove that a rejected registration leaves the repository unchanged.
Those are application questions, not policy questions.
Use Behat for product behavior exposed through commands and queries:
registering a user;
paying an order and granting access;
accepting an invitation only once;
publishing a versioned legal document;
creating an alert after a monitored change.
The scenario executes the real handler and domain collaborators through the application boundary, but technical ports can be replaced with in-memory implementations.
I require at least one application scenario for every command and query handler, then add rejection scenarios for the important policies and invariants reachable through that use case. This turns missing application coverage into a visible architectural gap instead of a judgment call during review.
Use the real technology when the technology is the behavior:
Doctrine mappings and repository queries;
DBAL filtering, pagination, JSONB, and projections;
unique constraints and locking;
filesystem adapters;
provider protocol adapters;
message routing and middleware behavior.
An in-memory repository cannot prove that a PostgreSQL partial unique index works. A Doctrine test cannot explain a subscription rule as clearly as a Behat scenario. I need both, but for different reasons.
@identity @application
Feature: User account
To protect access to customer accounts
As the identity application
I need registration to enforce account rules
Scenario: Registering a user starts account verification
Given current time is "2026-07-13 10:00:00"
When a user registers with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | Password1! |
Then the user should be pending verification
And the user should have the following profile:
| name | email | phoneNumber |
| Jane Doe | jane@example.test | +243970000001 |
And the registration should be announced
And email verification should be requested
The feature says what the application promises. It does not mention RegisterUserHandler, Doctrine, UUID factories, or an event-dispatcher mock.
Technical language is allowed inside the context implementation. Gherkin should use the language a product engineer or domain expert can challenge.
Compare these steps:
Plain text
Then a UserRegistered event should exist in the fake dispatcher
Plain text
Then the registration should be announced
The second describes why the event matters. The context can still assert the exact event class.
A context should translate scenario input into an application message, just as a controller translates HTTP input:
PHP
finalclassUserContextextendsAbstractContext{publicfunction__construct(privatereadonlyUserRepository$users,){}#[When('a user registers with the following details:')]publicfunctionaUserRegisters(TableNode$details):void{$row=$this->singleRow($details,['name','email','phoneNumber','password',]);$this->handleCommand(newRegisterUserFromInput(name:$row['name'],email:$row['email'],phoneNumber:$row['phoneNumber'],password:$row['password'],));}}
The context must not call User::register() directly for the action under test. Doing so would skip input conversion, handler routing, application services, policies, persistence, and events. Those are the collaborations the scenario is meant to verify.
The path under test is:
Mermaid
This is a real use case with replaceable edges, not a large unit test of the context class.
This service-subscriber base keeps shared test plumbing available without forcing every child context to repeat bus and storage constructor arguments. Individual contexts constructor-inject only their scenario-specific collaborators.
The important part is that controllers and Behat use the same CommandBus and QueryBus abstractions. Symfony Messenger still resolves the real message handler. Nested command dispatch and exception unwrapping still happen through the application seam.
The test environment may remove infrastructure-specific middleware such as real database transaction management when all repositories are in memory. Test the middleware separately through focused integration or unit tests instead of pretending an array repository proves transaction behavior.
Register contexts, hooks, and test adapters as services in the test container:
Behat and its Symfony extension evolve independently, so verify that the selected versions support your Symfony and PHP versions before copying the configuration.
Application specifications usually benefit from the second style. They observe the state or result promised by the use case rather than the precise sequence of collaborator calls.
Mocks still have a place when the interaction itself is the contract. One example is checking that a payment gateway receives one call with an idempotency key. Mocking every repository method, however, tends to make application tests mirror handler implementation.
finalreadonlyclassTimeContextimplementsContext{publicfunction__construct(privateTestClock$clock){}#[Given('current time is :time')]publicfunctioncurrentTimeIs(string$time):void{$this->clock->setNow(newDateTimeImmutable($time));}}
Now expiry boundaries are explicit:
Plain text
Given current time is "2026-07-13 10:16:00"
When the security challenge is consumed
Then it should be rejected because the token expired
Avoid hidden default dates when the date affects business behavior. Put the time in the scenario so a reader can audit the boundary.
A successful application command may publish domain events. For application specifications, I often want to prove the announcement occurred without sending real email or invoking every asynchronous subscriber.
Then the registration should be announced
And email verification should be requested
The context maps those statements to exact event types:
PHP
#[Then('the registration should be announced')]publicfunctionregistrationShouldBeAnnounced():void{$this->assertEventWasRecorded(UserRegistered::class);}#[Then('email verification should be requested')]publicfunctionverificationShouldBeRequested():void{$this->assertEventWasRecorded(SecurityChallengeIssued::class);}
This tests that the use case announced meaningful facts. Separate message-transport tests should prove routing, retry behavior, and worker configuration.
A domain exception deserves more than an isolated policy test when users can reach it through an application command.
Plain text
Scenario: Rejecting a registration with an email already used
Given current time is "2026-07-13 10:00:00"
And a registered user exists with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | Password1! |
When another user registers with the following details:
| name | email | phoneNumber | password |
| Jane Duplicate | jane@example.test | +243970000002 | Password1! |
Then registration should be rejected because the email is already used
The context captures the real exception from the command bus:
#[Then('registration should be rejected because the email is already used')]publicfunctionregistrationShouldBeRejected():void{self::assertInstanceOf(EmailAlreadyUsed::class,$this->caughtThrowable('identity.registration.error'),);}
This proves the handler actually reaches the uniqueness policy and that the production bus preserves the domain failure.
Testing only the exception is incomplete. A command can throw the right error after already changing something.
Add a negative state assertion:
Plain text
Scenario: Rejecting a registration with a weak password
Given current time is "2026-07-13 10:00:00"
When a user tries to register with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | password! |
Then registration should be rejected because the password is weak
And no account should be registered for that email
PHP
#[Then('no account should be registered for that email')]publicfunctionnoAccountShouldBeRegistered():void{$email=$this->sharedStorage->get('identity.registration.email');self::assertIsString($email);self::assertNull($this->users->findByEmail(newEmailAddress($email)),);}
For a payment failure, assert the order was not marked paid. For a rejected publication, assert no public version exists. For a failed bulk command, assert every aggregate remains unchanged.
The rejection and its state consequence form one behavior.
Do not turn shared storage into a hidden fixture container.
Store:
IDs produced by previous commands;
command or query results;
tokens observed from recorded events;
exceptions asserted by later steps.
Avoid storing aggregates only so a later step can call methods on them. Reload through a repository or execute a query. That keeps the scenario at the application boundary.
Typed accessors make failures obvious:
PHP
publicfunctionobject(string$key,string$expectedClass):object{$value=$this->storage[$key]??null;if(!$valueinstanceof$expectedClass){thrownewLogicException(sprintf('Expected "%s" to contain %s.',$key,$expectedClass,));}return$value;}
Every new stateful test adapter must participate in reset. If that list becomes too large, compose resettable adapters behind a small registry rather than allowing hidden cross-scenario state.
Scenarios must pass individually, in random order, and as a full suite.
Application specifications are not only for commands.
Plain text
Scenario: Listing policy documents
Given the following policy documents exist:
| slug | title |
| privacy | Privacy Policy |
| terms-of-use | Terms of Use |
When policy documents are listed through the application
Then the policy document list should contain:
| slug |
| privacy |
| terms-of-use |
The step executes the real query handler:
PHP
#[When('policy documents are listed through the application')]publicfunctionlistPolicyDocuments():void{$result=$this->handleQuery(newListPolicyDocuments());$this->sharedStorage->set('policy.document_list',$result);}
This proves handler wiring, access-aware orchestration, and projection mapping.
The in-memory read model does not prove the production SQL. Give the DBAL implementation separate tests for filtering, sorting, pagination, joins, and database-specific expressions.
Application Behat specifications boot the test container so real handlers and aliases can be resolved, but they deliberately avoid HTTP and real persistence.
the platform and security guards reject the wrong caller;
a user-facing exception becomes the expected problem document;
response serialization preserves the public contract.
Avoid repeating the full password matrix through HTTP. One representative validation or conflict path is enough to prove translation. The policy matrix already belongs in the unit test.
This makes database tests isolated without rebuilding the schema for every method.
Use migrations to create the test database schema. Use the smallest fixture that proves the query. A repository test should not import a production-sized data dump.
In a monorepo, frontend package tests protect the TypeScript boundary:
Zod response parsing;
endpoint construction;
query-string serialization;
TanStack Query keys;
platform facade visibility;
error normalization.
TypeScript
it("rejects an invalid user-list response",()=>{expect(()=>
listUsersResponseSchema.parse({
items:[{ userId:42}],
pagination:null,}),).toThrow();});
Do not recreate subscription rules in TypeScript mocks. Backend business behavior belongs in backend application specifications. Frontend tests prove adaptation and presentation contracts.
The narrowest useful test is usually faster, clearer, and easier to diagnose. "Narrowest" does not mean "mock every collaborator." It means selecting the smallest boundary that still contains the behavior.
Interaction-heavy tests can pass while application wiring, bus routing, policies, and real state outcomes are broken. Keep narrow mocks where valuable, but add use-case specifications through the bus.
Network tests become slow, expensive, and nondeterministic. Test your adapter contract locally and reserve provider sandbox tests for a controlled integration pipeline.
Assert observable state, returned models, domain rejections, and meaningful announcements. Refactoring internal method calls should not rewrite every application spec.
Testing a DDD application beyond unit tests does not mean turning every scenario into a slow end-to-end browser test.
Unit tests protect precise domain rules and state transitions.
Behat scenarios describe complete application behavior in product language.
Contexts act like controllers and dispatch real commands and queries.
In-memory adapters replace infrastructure ports without replacing handlers or domain logic.
A test clock makes time boundaries explicit and deterministic.
Recording adapters observe domain announcements without running unrelated consumers.
Rejection scenarios assert both the exception and the absence of partial state.
Small, namespaced shared storage connects steps without becoming a hidden fixture dump.
Reset hooks keep every scenario isolated.
Symfony functional tests protect HTTP, security, validation, and serialization.
Real Doctrine tests protect mappings, constraints, queries, and database semantics.
Purpose-built fakes isolate payment, storage, email, AI, and messaging providers.
Frontend package tests protect contracts without duplicating backend rules.
The result is a set of executable boundaries. Policy tests explain rule edges. Behat scenarios identify broken orchestration by use case. Focused Doctrine and Symfony tests point to the failing adapter. The suite can support architectural refactoring without discarding the product rules encoded in it.