Testing strategy
Summary. The kit tests behaviour through public interfaces at four levels (domain, application, adapter, end to end), with the same test data builders at every level. Test doubles are fakes, working in-memory implementations of ports, rather than mocks; a fake is kept honest by passing the same contract suite as the real adapter it stands for. Aggregate specs read given / when / then. The result is a suite where most tests run in milliseconds with no infrastructure, survive refactoring because they assert outcomes rather than calls, and read as business rules. This page explains each choice, says what to test where, and states what the strategy does not cover.
The vocabulary of test doubles
Section titled “The vocabulary of test doubles”"Mock" is often used for any stand-in. Gerard Meszaros's vocabulary, popularised by Martin Fowler, is more precise:
| Double | What it is | Verifies |
|---|---|---|
| dummy | passed but never used | nothing |
| stub | returns canned answers | state, indirectly |
| spy | a stub that records how it was called | calls, after the fact |
| mock | pre-programmed with the calls it expects | calls: the test fails if they differ |
| fake | a working implementation with a shortcut (in memory instead of a database) | state |
The distinction that matters is the last column. A test with mocks asserts an interaction:
"save was called once with this object". A test with a fake asserts an outcome: "the
appointment is stored and cancelled".
Fakes, not mocks
Section titled “Fakes, not mocks”// instead of, with a mock: asserts how the handler talks to its collaboratorsconst appointments = { findById: vi.fn().mockResolvedValue(appointment), save: vi.fn() };// the access check is mocked too: the test now knows every collaborator of the handlerconst access = { authorize: vi.fn().mockResolvedValue(undefined) };const handler = new CancelAppointmentHandler(appointments, access, clock);
await handler.execute(new CancelAppointmentCommand(patient, appointment.id));
expect(appointments.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'cancelled' }));
// write, with a fake: asserts what is true afterwardsconst appointments = new FakeAppointmentRepository();await appointments.save(appointment);
const app = createTestApplication({ modules: [platformTestModule, contextModule('scheduling', schedulingDeclarations), holders], fakes: [fake(AppointmentRepository, appointments)],});
await app.commandBus.execute(new CancelAppointmentCommand(patient, appointment.id));
expect((await appointments.findById(appointment.id))?.status).toBe('cancelled');expect(app.eventBus.published).toMatchObject([{ type: 'scheduling.appointment.cancelled' }]);Why the kit prefers the second form (rule
test/no-mocks, a warning by default):
- Mocks couple tests to the implementation. Rename a method, save in two steps instead of one, load through another finder: behaviour is unchanged, mock-based tests fail. Tests that break on refactoring discourage refactoring.
- Mocks can assert a lie. A mocked repository returns whatever the test says, including things the real one never would. The test passes; production does not.
- Fakes are reusable. One in-memory repository serves every handler spec of the context,
and a failure is a method on the fake (
mailer.failNextWith(...)) rather than a setup repeated in each test. - The architecture makes mocks unnecessary. Every dependency is a port received by constructor; time and identifiers are ports too. A class that needs a module mock to be tested is breaking a purity rule (Clean code).
This is the "classical" school in Fowler's terms. The "mockist" school, described by Freeman and Pryce, uses mocks to discover interfaces while designing outside-in; it is a coherent practice, and its authors also warn against mocking types you do not own. The kit's position is narrower than a doctrine: its ports are already designed and each has a fake, so interaction assertions add coupling without adding information.
The one accepted double that is not a fake: a stub of an outside HTTP API at the network level, in an adapter's integration spec.
A fake is a small class of test/fakes/ that implements the port with a shortcut, and offers
the failures a test needs as methods. The mailer of the notification context of the example,
shortened:
export abstract class Mailer { /** Fails when the provider did not take the mail. */ abstract deliver(address: string, subject: string): Promise<void>;}
// notification/test/fakes/mailer.fake.tsexport class FakeMailer implements Mailer { /** What the provider took, in order: what a spec asserts. */ readonly delivered: { readonly address: string; readonly subject: string }[] = [];
private readonly failures: Error[] = [];
/** The next delivery fails with this error, then the mailer works again. */ failNextWith(error: Error): void { this.failures.push(error); }
async deliver(address: string, subject: string): Promise<void> { const failure = this.failures.shift();
if (failure !== undefined) { throw failure; }
this.delivered.push({ address, subject }); }}A spec then reads like the rule it proves: mailer.failNextWith(new Error('provider unreachable')), send the notification, and expect that nothing was saved and nothing published.
Contract tests keep fakes honest
Section titled “Contract tests keep fakes honest”The weakness of a fake is drift: an in-memory repository that tolerates what PostgreSQL would refuse. The answer is a contract suite: one set of tests written against the port, run against the fake and against the real adapter.
flowchart TB C[Contract suite of the port: save then find, unknown id, update, optimistic concurrency...] C --> F[FakeCalendarRepository, in test/fakes] C --> R[Real adapter on PostgreSQL] F --> H[used by every handler spec] R --> P[used in production]
In code, one call per implementation, with the same expectations. The spec of the calendar repository of the example, shortened:
import { afterEach, beforeEach, describe, expect, it } from 'vitest';import { AggregateRoot, type Repository } from '@ddd-ts-kit/kernel';import { InMemoryRepository } from '@ddd-ts-kit/testing';import { repositoryContract } from '@ddd-ts-kit/testing/contracts';
// from the domain, the builders and the test database of the projectinterface CalendarState { readonly exceptions: readonly string[];}declare class Calendar extends AggregateRoot { static rehydrate(id: string, state: CalendarState): Calendar; addException(day: string, at: Date): void; toState(): CalendarState;}declare function aCalendar(): { build(): Calendar };declare function knexCalendarRepositoryOnAFreshDatabase(): Promise<Repository<Calendar>>;
const runner = { describe, it, expect, beforeEach, afterEach };
const expectations = { createAggregate: () => aCalendar().build(), mutate: (calendar: Calendar) => calendar.addException('2026-12-25', new Date('2026-01-02T00:00:00.000Z')), idOf: (calendar: Calendar) => calendar.id, stateOf: (calendar: Calendar) => calendar.toState(),};
// the fake every handler spec usesrepositoryContract<Calendar>(runner, { ...expectations, name: 'FakeCalendarRepository', createRepository: () => new InMemoryRepository<Calendar>({ clone: (stored) => Calendar.rehydrate(stored.id, stored.toState()) }),});
// the adapter production uses, on a real databaserepositoryContract<Calendar>(runner, { ...expectations, name: 'KnexCalendarRepository', createRepository: () => knexCalendarRepositoryOnAFreshDatabase(),});If both pass the same suite, a handler tested on the fake behaves the same on the adapter for
everything the contract covers. The kit ships suites for its own ports in
@ddd-ts-kit/testing/contracts (repository, unit of work, read model store and criteria, event
bus, event store, outbox store, processed-event store, process store, job store, job scheduler,
cache, cache purger, lock, rate limiter, file storage, logger, tenant isolation) and in
@ddd-ts-kit/auth/testing (authorizer, relation tuple store), and ContractSuite, the base to
write the suite of a port of your own. Replacing an ORM or a broker is then "write the adapter,
pass the suite".
A contract covers what it tests and nothing more: behaviour specific to an adapter (the mapping of every column, a transaction rollback) gets its own integration tests.
Builders
Section titled “Builders”Test data comes from builders, one per aggregate:
const appointment = anAppointment().forPatient('ada').startingAt(tomorrowAt9).cancelledByPatient().build();A builder extends Builder of @ddd-ts-kit/testing: valid defaults() once, a construct()
that goes through the public API of the aggregate, and one method per word of the language.
Shortened from the builder of the example:
import { AggregateRoot } from '@ddd-ts-kit/kernel';import { Builder, sequence, type Defaults } from '@ddd-ts-kit/testing';
// the aggregate, from the domaindeclare class Appointment extends AggregateRoot { static create(id: string, patientId: string, startsAt: Date, bornAt: Date): Appointment; cancel(by: 'patient' | 'practice', at: Date): void;}
interface AppointmentIngredients { readonly id: string; readonly patientId: string; readonly startsAt: Date; readonly bornAt: Date; readonly cancelled: boolean;}
export class AppointmentBuilder extends Builder<AppointmentIngredients, Appointment> { forPatient(patientId: string): this { return this.with('patientId', patientId); }
startingAt(instant: Date): this { return this.with('startsAt', instant); }
cancelledByPatient(): this { return this.with('cancelled', true); }
protected defaults(): Defaults<AppointmentIngredients> { return { id: sequence('appointment'), patientId: 'patient-1', startsAt: new Date('2026-03-02T09:00:00.000Z'), bornAt: new Date('2026-03-01T00:00:00.000Z'), cancelled: false, }; }
/** Through the public API only: a test can never start from a state the rules forbid. */ protected construct({ id, patientId, startsAt, bornAt, cancelled }: AppointmentIngredients): Appointment { const appointment = Appointment.create(id, patientId, startsAt, bornAt);
if (cancelled) { appointment.cancel('patient', bornAt); }
return appointment; }}
export function anAppointment(): AppointmentBuilder { return new AppointmentBuilder();}- Valid defaults.
anAppointment().build()is a valid appointment. A test states only what matters to the rule it checks, so the reader sees the cause of the outcome. - Named in the ubiquitous language.
cancelledByPatient(), notwithStatus('CANCELLED'). - Built through the public API.
build()goes through the aggregate's factory and intention methods, so a test cannot start from an invalid state. If a test needs a state the public API cannot reach, either the state is impossible or the API is incomplete. - Immutable. Each call returns a new builder, so a shared base cannot leak between tests.
Rule test/builder-required. Without builders, every
change of a constructor edits fifty tests and each test buries its one relevant value under ten
irrelevant ones.
Given / when / then
Section titled “Given / when / then”An aggregate spec is a scenario: a built state (or past events), one intention, then the recorded events or the raised error.
import { describe, expect, it } from 'vitest';import { AggregateRoot } from '@ddd-ts-kit/kernel';import { Builder, given } from '@ddd-ts-kit/testing';
// the aggregate and its builder, from the previous sampledeclare class Appointment extends AggregateRoot { readonly status: 'booked' | 'cancelled'; cancel(by: 'patient' | 'practice', at: Date): void; reschedule(startsAt: Date, at: Date): void;}declare abstract class AppointmentBuilder extends Builder<object, Appointment> { cancelledByPatient(): this;}declare function anAppointment(): AppointmentBuilder;
const at = new Date('2026-03-01T08:00:00.000Z');
describe('Appointment', () => { it('records that it was cancelled', () => { given(anAppointment()) .when((appointment) => appointment.cancel('patient', at)) .thenEvents([{ name: 'scheduling.appointment.cancelled', occurredAt: at }]) .thenState((appointment) => expect(appointment.status).toBe('cancelled')); });
it('refuses to reschedule a cancelled appointment', () => { given(anAppointment().cancelledByPatient()) .when((appointment) => appointment.reschedule(new Date('2026-03-03T09:00:00.000Z'), at)) .thenError('APPOINTMENT_ENDED'); });});given takes the builder, builds the aggregate and forgets the events recorded while
building it, so thenEvents sees only those of when. thenError takes a code, or the class
of the error and a code.
One intention per test, and assertions on events, errors and state through the public API
only. The test name states the rule: refuses to reschedule a cancelled appointment.
The four levels
Section titled “The four levels”| Level | What runs | Doubles | Speed |
|---|---|---|---|
| domain | aggregates, value objects, domain services | none at all | milliseconds |
| application | real handlers through the in-memory buses | fakes for every port, a fixed clock | milliseconds |
| adapter | one real adapter against its real dependency, plus the contract suite | none | seconds |
| end to end | the whole application with real adapters, through HTTP and the event bus | none; generated data with a fixed seed | seconds |
Normative table: section 7 of the architecture reference.
An architecture level sits beside them: ddd-ts-kit check, and a generated test that boots
each context alone to prove it stays extractable.
The same builders serve every level. End-to-end tests create their state through the public API, use generated data with a seed printed on failure so a failing run can be replayed, and assert what a client observes (responses, published events), never the database directly.
What to test where
Section titled “What to test where”| Behaviour | Level | Why there |
|---|---|---|
| "A cancelled appointment cannot be rescheduled" | domain | an invariant: no I/O needed, exhaustive cases are cheap |
Boundaries of TimeSlot (end before start, zero length) |
domain, table-driven | value object validation |
"Cancelling publishes scheduling.appointment.cancelled once the command handler has returned; a refusal saves and publishes nothing" |
application | orchestration: load, one intention, save |
| Each declared error code of a use case | application | the contract of the handler |
"An actor without appointment.cancel is refused" |
application, with the authorization module listed | a property of the wiring of the context |
| "Only the client who booked may cancel" (the policy) | unit, on the policy alone | a pure function |
| Mapping of every column, optimistic concurrency, rollback | adapter | only the real database can say |
| The exclusion constraint that prevents double booking | adapter | the constraint lives in the database |
| Fake and adapter agree | contract, at the adapter level | keeps the application level trustworthy |
| 401 without a credential, 403 without the permission, the documented status with it | end to end | the whole chain: HTTP, authentication, bus |
| "Booking leads to a reminder being scheduled" across two contexts | end to end | crosses the event bus; eventual, so the test waits for the outcome |
| Status code and problem body for an input of the wrong shape | route spec | boundary validation |
Two guidelines follow from the table. Push each rule to the lowest level that can prove it: ten cases of a cancellation window are ten millisecond tests on the aggregate, not ten HTTP round trips. And do not repeat a rule at every level: the end-to-end test proves the wiring with one case, the domain test proves the rule with all of them.
Every unit of behaviour has a spec (rule
test/spec-required), and a test contains no logic:
no if, no loop, arrange, act and assert separated by blank lines.
Coverage thresholds
Section titled “Coverage thresholds”A project states the minimum coverage of each layer in one place, the coverage key of
ddd-ts-kit.config.json (CONFIG.md); the test runner reads
it, and npm run verify and the pipeline fail under it. The reference, as statements,
branches, functions, lines:
| Layer | Reference | Why this floor | Measured by |
|---|---|---|---|
domain/ |
90, 85, 90, 90 | pure code, nothing prevents testing it; it holds the rules | unit specs |
application/ |
85, 80, 85, 85 | handlers run through fakes | unit specs |
presentation/ |
80, 70, 80, 80 | mapping and status translation; its error paths are also held end to end | unit specs |
infrastructure/ |
70, 60, 70, 70 | driver failure paths are hard to provoke; adapters are held by contract suites | integration specs |
Each context is held on its own, layer by layer, from the first source file of the layer. A
freshly generated project passes: what a generator writes comes with a spec that runs it, and
the it.todo entries name the behaviour still to prove.
What a rate proves. That a line, a branch or a function ran while a test was running. A layer under its floor holds code no test has ever executed: that is certain, and it is what the gate catches.
What it does not prove. That anything was asserted about what ran, that the cases that matter were tried, or that the test would fail if the behaviour changed. A spec that calls a handler and checks nothing covers it fully. The specs named after business rules, the contract suites and the end-to-end journeys prove behaviour; coverage only says where nothing does.
A floor, not a goal. Raise a threshold when a layer has stayed above it; never write a
test to move a number. A threshold is lowered only by a recorded deviation, and the measure is
never narrowed around the code that fails it (test/coverage-governed): an uncovered branch
is tested, or deleted when nothing can reach it.
// instead of, in vitest.config.ts: a measure narrowed by hand, refused by test/coverage-governedtest: { coverage: { exclude: ['src/contexts/billing/infrastructure/**'], thresholds: { branches: 50 } } }
// write: the runner reads what it measures and its floors from ddd-ts-kit.config.jsontest: { coverage: { provider: 'v8', ...governedCoverage(mode) } } // from @ddd-ts-kit/testing/coverage"coverage": { "infrastructure": { "branches": 50 } },"deviations": [ { "what": "coverage.infrastructure.branches", "adr": "0011", "reason": "The failure branches of the driver are held by its contract suite." }]Limits and when not to use it
Section titled “Limits and when not to use it”- Mutation testing is not shipped. It is the tool that measures whether tests would notice a changed behaviour. The pipeline README records why: Stryker 10, run on the domain layer of a generated project with Vitest 5, executed no test per mutant and reported every mutant as survived, and its core rewrites the tsconfig through an API the native TypeScript 7 does not have. A gate that scores zero whatever the tests say is worse than no gate. Until the runner supports it, test quality rests on review and on the conventions above.
- A fake is only as good as its contract suite. Behaviour the suite does not cover can differ silently: SQL collation and ordering, constraint timing, time zone handling. When a bug comes from such a gap, add the case to the suite, not only to one adapter.
- In-process PostgreSQL is not the production server. Fast adapter specs run on PGlite; a final integration run against the real server remains necessary, and the READMEs of the ORM adapters state that MySQL and SQLite are outside the automated suite.
- The existence rules check files, not content.
test/spec-requiredandtest/builder-requiredverify that the file exists; a spec holding onlyit.todosatisfies them. Leftover todos mean the work is not done. test/no-mocksis a warning by default and concerns the tests of a context. Legacy code being adopted, or a third-party SDK with no seam, may justify a mock; make it a recorded decision rather than a habit.- End-to-end tests of eventual behaviour are slower and need care. They wait for an outcome instead of sleeping, and a badly bounded wait makes a flaky test.
- No performance, load or security testing is part of this strategy, and no consumer-driven contract testing between separately deployed services: the generated OpenAPI and AsyncAPI documents with drift checks hold the contracts instead. After extracting a service, consider adding it (When to extract microservices).
Sources
Section titled “Sources”- Gerard Meszaros, xUnit Test Patterns, Addison-Wesley, 2007: Test Double and its variations (Dummy, Stub, Spy, Mock, Fake), Test Data Builder antecedents (Creation Method).
- Martin Fowler, "Mocks Aren't Stubs" (2007), "TestDouble", "TestPyramid", "ContractTest" and "GivenWhenThen", martinfowler.com.
- Steve Freeman and Nat Pryce, Growing Object-Oriented Software, Guided by Tests, Addison-Wesley, 2009: the mockist practice, "only mock types that you own", test data builders (chapter 22).
- Nat Pryce, "Test Data Builders: an alternative to the Object Mother pattern", 2007 (natpryce.com).
- Vladimir Khorikov, Unit Testing Principles, Practices, and Patterns, Manning, 2020: resistance to refactoring as a property of a good test.
- Dan North, "Introducing BDD", 2006 (dannorth.net): given / when / then.
@ddd-ts-kit/testingandpipeline/README.md.
See also
Section titled “See also”- Clean code
- Aggregates
- Ports and composition
- Delivery and idempotency
- Test runners
- Static analysis and pipeline
- Architecture reference: sections 5 and 7, rules
test/spec-required,test/no-mocks,test/builder-required