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

Cover image for 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.

Mermaid

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:

PHP
final class PasswordPolicyTest extends TestCase
{
    #[DataProvider('weakPasswords')]
    public function testItRejectsWeakPasswords(
        string $password,
        string $expectedErrorId,
    ): void {
        try {
            $this->policy()->ensureSatisfiedBy(new PlainPassword($password));
        } catch (WeakPassword $error) {
            self::assertSame($expectedErrorId, $error->id());

            return;
        }

        self::fail('Weak password was accepted.');
    }

    public static function weakPasswords(): 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.

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:

Plain text
@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.

Step 3: Treat Behat Contexts like Controllers

A context should translate scenario input into an application message, just as a controller translates HTTP input:

PHP
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:

Mermaid

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:

PHP
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:

YAML
# 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:

PHP
// 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:

PHP
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:

PHP
#[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:

PHP
#[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:

PHP
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:

PHP
$users->expects(self::once())
    ->method('save');

An in-memory adapter provides working behavior:

PHP
$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:

PHP
interface Clock
{
    public function now(): DateTimeImmutable;
}

The test adapter wraps Symfony’s mock clock:

PHP
#[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:

PHP
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:

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.

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:

PHP
#[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:

Plain text
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')]
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.

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:

PHP
protected function catchThrowable(string $key, callable $action): void
{
    try {
        $action();
        $this->sharedStorage->remove($key);
    } catch (Throwable $throwable) {
        $this->sharedStorage->set($key, $throwable);
    }
}
PHP
#[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:

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')]
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:

Plain text
identity.user_id
identity.verification_token
identity.registration.error
billing.order
access.subscription
corpus.expression

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
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:

PHP
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.

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')]
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:

PHP
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:

YAML
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: true

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.

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:

PHP
#[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:

YAML
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.

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.

Step 17: Choose the Narrowest Test That Can Fail for the Right Reason

Before writing a test, ask:

Mermaid

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:

Bash
# 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 test

Run 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!

Related writing