Testing DDD Applications beyond Unit Tests: Behat, In-Memory Adapters, and Real Use Cases

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.
Ever had a test suite full of green unit tests while the application was still broken?
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.
-
Focused Symfony functional tests protect HTTP, security, mapping, Doctrine, and DBAL integration.
-
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().
In this post, I’ll show how I built this testing architecture and how you can implement the same approach in a generic Symfony and DDD project.
The Goal Is Confidence at the Right Boundary
A test is valuable only if I know what boundary it protects.
The layers overlap intentionally, but they do not duplicate the same assertion.
For a registration use case:
-
A unit test proves exactly why a password is weak.
-
A Behat scenario proves registration actually invokes that policy and stores no account after rejection.
-
An HTTP test proves malformed JSON and authorization are mapped correctly.
-
A repository test proves email uniqueness and persistence mapping in PostgreSQL.
Each failure points to a smaller part of the system.
Why Unit Tests Are Necessary but Not Sufficient
The classic unit test for a password policy is useful:
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.
Step 1: Decide What Belongs in Each Test Layer
I use four backend test categories.
Domain Unit Tests
Use PHPUnit without booting Symfony for:
-
value-object validation and normalization;
-
aggregate state transitions;
-
domain events recorded by an entity;
-
policies and domain services;
-
calculation matrices and edge cases.
These tests should be able to run without Doctrine, HTTP, Messenger, Redis, or a service container.
Application Specifications
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.
Symfony Functional Tests
Use focused PHPUnit kernel or web tests for technical integration:
-
route and status-code contracts;
-
request payload mapping and validation;
-
authentication, authorization, and platform guards;
-
exception-to-problem response translation;
-
serialization and important response shapes;
-
email delivery integration when transport behavior matters.
Do not rewrite a complete business workflow through HTTP merely because the endpoint exists. The application behavior already belongs in Behat.
Infrastructure Tests
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.
Step 2: Write the Use Case in Business Language
My representative feature is registration:
@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 requestedThe 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:
Then a UserRegistered event should exist in the fake dispatcherThen the registration should be announcedThe second describes why the event matters. The context can still assert the exact event class.
Step 3: Treat Behat Contexts like Controllers
A context should translate scenario input into an application message, just as a controller translates HTTP input:
final class UserContext extends AbstractContext
{
public function __construct(
private readonly UserRepository $users,
) {}
#[When('a user registers with the following details:')]
public function aUserRegisters(TableNode $details): void
{
$row = $this->singleRow($details, [
'name',
'email',
'phoneNumber',
'password',
]);
$this->handleCommand(new RegisterUserFromInput(
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—the exact collaborations the scenario is meant to verify.
The path under test is:
This is a real use case with replaceable edges, not a large unit test of the context class.
Step 4: Execute through the Same Bus as Production
My base context exposes the application bus contracts:
abstract class AbstractContext extends Assert implements Context, ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
public static function getSubscribedServices(): array
{
return [
CommandBus::class,
QueryBus::class,
SharedStorage::class,
];
}
protected function handleCommand(object $command): mixed
{
return $this->container
->get(CommandBus::class)
->handle($command);
}
protected function handleQuery(object $query): mixed
{
return $this->container
->get(QueryBus::class)
->handle($query);
}
}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:
# config/services_test.yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: true
App\Tests\Support\:
resource: '../tests/Support/'
App\Tests\Behat\Context\:
resource: '../tests/Behat/Context/'
App\Tests\Behat\Hook\:
resource: '../tests/Behat/Hook/'
App\Tests\Behat\SharedStorage: ~Then configure Behat to boot Symfony’s test kernel and resolve contexts from the container:
// behat.php
return new Config()
->withProfile(
new Profile('default')
->withExtension(new Extension(SymfonyExtension::class, [
'bootstrap' => 'tests/bootstrap.php',
'kernel' => [
'class' => Kernel::class,
'environment' => 'test',
'debug' => true,
],
]))
->withSuite(
new Suite('application')
->withPaths('tests/Behat/features')
->addContext(ApplicationStateHook::class)
->addContext(TimeContext::class)
->addContext(UserContext::class),
),
);Behat and its Symfony extension evolve independently, so verify that the selected versions support your Symfony and PHP versions before copying the configuration.
Step 5: Replace Ports, Not the Use Case
The domain declares a repository interface:
interface UserRepository
{
public function save(User $user): void;
public function get(UserId $id): User;
public function findByEmail(EmailAddress $email): ?User;
public function findByPhoneNumber(PhoneNumber $phoneNumber): ?User;
}Production uses Doctrine. Behat uses an in-memory adapter implementing the same port:
#[AsAlias(UserRepository::class, when: 'test')]
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> */
private array $users = [];
public function save(User $user): void
{
$this->users[(string) $user->id] = $user;
}
public function get(UserId $id): User
{
return $this->users[(string) $id]
?? throw UserNotFound::withId($id);
}
public function findByEmail(EmailAddress $email): ?User
{
foreach ($this->users as $user) {
if ($user->email->equals($email)) {
return $user;
}
}
return null;
}
public function reset(): void
{
$this->users = [];
}
}The test-specific alias is the key:
#[AsAlias(UserRepository::class, when: 'test')]Application code still asks for UserRepository. It never knows which adapter was selected.
The Behat context should usually inject the interface too:
public function __construct(
private readonly UserRepository $users,
) {}Concrete in-memory types are appropriate only in reset hooks or when a fake exposes intentionally test-specific recording behavior.
In-Memory Adapters Are Not Mocks
A mock answers questions about interactions:
$users->expects(self::once())
->method('save');An in-memory adapter provides working behavior:
$saved = $users->findByEmail(new EmailAddress('jane@example.test'));
self::assertInstanceOf(User::class, $saved);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—for example, ensuring a payment gateway is called once with an idempotency key. But mocking every repository method tends to make application tests mirror handler implementation.
Step 6: Make Time a Test Port
Time-dependent tests become flaky when production code calls new DateTimeImmutable() internally.
The application depends on a clock:
interface Clock
{
public function now(): DateTimeImmutable;
}The test adapter wraps Symfony’s mock clock:
#[AsAlias(Clock::class, when: 'test')]
final class TestClock implements Clock
{
private MockClock $clock;
public function __construct()
{
$this->setNow(new DateTimeImmutable('2026-07-13 10:00:00'));
}
public function setNow(DateTimeImmutable $now): void
{
$this->clock = new MockClock($now);
}
public function now(): DateTimeImmutable
{
return DateTimeImmutable::createFromInterface($this->clock->now());
}
}Behat exposes time in the scenario:
final readonly class TimeContext implements Context
{
public function __construct(private TestClock $clock) {}
#[Given('current time is :time')]
public function currentTimeIs(string $time): void
{
$this->clock->setNow(new DateTimeImmutable($time));
}
}Now expiry boundaries are explicit:
Given current time is "2026-07-13 10:16:00"
When the security challenge is consumed
Then it should be rejected because the token expiredAvoid hidden default dates when the date affects business behavior. Put the time in the scenario so a reader can audit the boundary.
Step 7: Record Events without Running Unrelated Consumers
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.
Use a recording adapter:
#[AsAlias(EventDispatcher::class, when: 'test')]
final class InMemoryEventDispatcher implements EventDispatcher
{
/** @var list<object> */
public array $events = [];
public function dispatch(array $events): void
{
array_push($this->events, ...array_values($events));
}
public function reset(): void
{
$this->events = [];
}
}The Gherkin remains business-oriented:
Then the registration should be announced
And email verification should be requestedThe context maps those statements to exact event types:
#[Then('the registration should be announced')]
public function registrationShouldBeAnnounced(): void
{
$this->assertEventWasRecorded(UserRegistered::class);
}
#[Then('email verification should be requested')]
public function verificationShouldBeRequested(): 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.
Step 8: Test Rejections through the Full Use Case
A domain exception deserves more than an isolated policy test when users can reach it through an application command.
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 usedThe context captures the real exception from the command bus:
protected function catchThrowable(string $key, callable $action): void
{
try {
$action();
$this->sharedStorage->remove($key);
} catch (Throwable $throwable) {
$this->sharedStorage->set($key, $throwable);
}
}#[Then('registration should be rejected because the email is already used')]
public function registrationShouldBeRejected(): 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.
Step 9: Assert the Absence of Partial State
Testing only the exception is incomplete. A command can throw the right error after already changing something.
Add a negative state assertion:
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#[Then('no account should be registered for that email')]
public function noAccountShouldBeRegistered(): void
{
$email = $this->sharedStorage->get('identity.registration.email');
self::assertIsString($email);
self::assertNull(
$this->users->findByEmail(new EmailAddress($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.
Step 10: Keep Shared Scenario State Small and Named
Steps sometimes need to share an identifier, returned model, verification token, or captured exception.
Use a small scenario-local store with namespaced keys:
identity.user_id
identity.verification_token
identity.registration.error
billing.order
access.subscription
corpus.expressionDo 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:
public function object(string $key, string $expectedClass): object
{
$value = $this->storage[$key] ?? null;
if (! $value instanceof $expectedClass) {
throw new LogicException(sprintf(
'Expected "%s" to contain %s.',
$key,
$expectedClass,
));
}
return $value;
}Step 11: Reset Every Test Adapter before Each Scenario
In-memory state is still state. Without reset, scenarios depend on execution order.
I use a Behat BeforeScenario hook:
final readonly class ApplicationStateHook implements Context
{
public function __construct(
private SharedStorage $storage,
private TestClock $clock,
private InMemoryEventDispatcher $events,
private InMemoryUserRepository $users,
private InMemorySubscriptionRepository $subscriptions,
) {}
#[BeforeScenario]
public function reset(): void
{
$this->storage->clear();
$this->clock->setNow(
new DateTimeImmutable('2026-07-13 10:00:00'),
);
$this->events->reset();
$this->users->reset();
$this->subscriptions->reset();
}
}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.
Step 12: Test Queries through the Query Bus Too
Application specifications are not only for commands.
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:
#[When('policy documents are listed through the application')]
public function listPolicyDocuments(): void
{
$result = $this->handleQuery(new ListPolicyDocuments());
$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.
Step 13: Boot Symfony Only for Framework Questions
Application Behat specifications boot the test container so real handlers and aliases can be resolved, but they deliberately avoid HTTP and real persistence.
Use WebTestCase when HTTP itself is under test:
final class RegisterUserEndpointTest extends WebTestCase
{
public function testInvalidPayloadReturnsValidationProblem(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/identity/auth/register', [
'name' => '',
'email' => 'not-an-email',
]);
self::assertResponseStatusCodeSame(422);
self::assertResponseHeaderSame(
'content-type',
'application/problem+json',
);
}
}Useful functional assertions include:
-
the route accepts only the intended HTTP method;
-
the request model maps and validates input;
-
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.
Step 14: Test Doctrine with Doctrine
An array-backed repository cannot reproduce:
-
SQL collation;
-
unique constraints;
-
Doctrine mappings and custom DBAL types;
-
JSONB operators;
-
transaction isolation;
-
partial indexes;
-
joins, pagination, and sorting;
-
or database locking.
Write focused persistence tests against the real test database for those behaviors.
I use a separate test database suffix and DAMA Doctrine Test Bundle. It keeps a static connection and wraps PHPUnit tests in transactions:
when@test:
doctrine:
dbal:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: trueThis 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.
Step 15: Replace External Systems with Purpose-Built Fakes
Application tests should not call real payment gateways, object storage, AI providers, email providers, Redis, or realtime services.
Create a fake that models the port’s useful behavior:
#[AsAlias(PaymentGateway::class, when: 'test')]
final class InMemoryPaymentGateway implements PaymentGateway
{
/** @var list<PaymentRequest> */
public array $requests = [];
public ?Throwable $nextFailure = null;
public function charge(PaymentRequest $request): PaymentResult
{
$this->requests[] = $request;
if ($this->nextFailure instanceof Throwable) {
throw $this->nextFailure;
}
return PaymentResult::accepted('test-reference');
}
}The fake lets a scenario prove success, rejection, and retry-safe input without network nondeterminism.
In the test environment, route asynchronous Messenger transports to memory:
when@test:
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'Then assert that a message was dispatched. Test the actual worker, transport DSN, and retry policy separately where those technical guarantees matter.
Step 16: Protect Frontend Contracts without Repeating Backend Behavior
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.
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.
Step 17: Choose the Narrowest Test That Can Fail for the Right Reason
Before writing a test, ask:
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.
Running the Test Layers
Use focused commands while developing:
# Domain and technical PHPUnit tests
APP_ENV=test php -dmemory_limit=-1 ./vendor/bin/phpunit tests/Unit
# One application feature
APP_ENV=test php -dmemory_limit=-1 ./vendor/bin/behat \
tests/Behat/features/identity/user.feature
# Full backend suites
composer app:test
composer app:behat
# Frontend package contracts
bun --filter @app/api testRun the smallest relevant target first, then the complete suite before merging.
Common DDD Testing Mistakes
Testing Handlers Only with Mocks
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.
Driving Aggregates Directly from Behat Steps
That bypasses the application layer. Use commands and queries for actions under test.
Treating In-Memory Repositories as Persistence Tests
They prove the application’s use of the repository contract. They do not prove Doctrine or SQL behavior.
Writing Gherkin in Technical Language
Feature files should preserve business behavior, not PHP class names and mocking vocabulary.
Hiding Important Inputs in Step Definitions
Dates, actors, platforms, money, status, and identifiers should appear in the scenario when they influence behavior.
Testing Only Happy Paths
Policies, exceptions, expiry boundaries, ownership, idempotency, and partial-state prevention deserve explicit scenarios.
Sharing State between Scenarios
Reset every in-memory adapter, event recorder, fake provider, clock, and shared store before each scenario.
Duplicating the Same Matrix at Every Layer
Test the complete password matrix once in the policy. Use representative paths at the application and HTTP boundaries.
Calling Real External Services
Network tests become slow, expensive, and nondeterministic. Test your adapter contract locally and reserve provider sandbox tests for a controlled integration pipeline.
Verifying Private Implementation Details
Assert observable state, returned models, domain rejections, and meaningful announcements. Refactoring internal method calls should not rewrite every application spec.
Wrapping Up
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 not merely a bigger test suite. It is a set of executable boundaries.
When a domain rule changes, the policy test explains the edge cases. When orchestration breaks, the Behat scenario names the affected use case. When SQL or Symfony integration fails, a focused technical test points directly at the adapter.
That is the kind of test suite that lets a team refactor architecture without losing the product hidden inside it.
Happy coding!