@ddd-ts-kit/testing
Readable tests without mocks: test data builders, given / when / then scenarios for aggregates, an in-memory harness for handlers, an end-to-end harness over HTTP and the event bus, and contract suites that hold fakes and real adapters to the same behaviour. Business logic is tested with no infrastructure at all, and the same builders carry to the tests that run the whole application.
npm i -D @ddd-ts-kit/testing| Entry | Holds | Imports |
|---|---|---|
@ddd-ts-kit/testing |
builders, scenarios, createTestBuses, InMemoryRepository |
no runner, no library |
@ddd-ts-kit/testing/faker |
generated data for builders | @faker-js/faker (optional peer) |
@ddd-ts-kit/testing/e2e |
HTTP client, eventually, collectEvents |
Node only: no HTTP framework, no container |
@ddd-ts-kit/testing/contracts |
contract suites of the kernel ports, ContractSuite to write another |
the runner you pass |
@ddd-ts-kit/testing/vitest |
decorators(), the Vite plugin that lets Vitest run standard decorators, and decoratorCoverage() |
Node only; runs the tsc of the project |
@ddd-ts-kit/testing/coverage |
governedCoverage(), governedJestCoverage(), unaffectedContexts(): the coverage the runner measures and its floor, read from ddd-ts-kit.config.json |
Node only |
The main entry, /contracts and /e2e follow the
test levels; the builders serve every level, and the
other entries are set up once in the runner config:
A first test
Section titled “A first test”One aggregate, its builder and two scenarios, in one file for the reading (a project gives the
aggregate its file under domain/ and the builder its file under test/builders/):
import { AggregateRoot, ConceptError, type DomainEvent } from '@ddd-ts-kit/kernel';import { Builder, given, resetSequences, sequence, type Defaults } from '@ddd-ts-kit/testing';import { beforeEach, expect, it } from 'vitest';
// The model under test.class OrderError extends ConceptError { static alreadyPaid(orderId: string) { return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId }); }}
class OrderPaid implements DomainEvent { static readonly eventName = 'ordering.order.paid';
readonly name = OrderPaid.eventName;
constructor( readonly aggregateId: string, readonly occurredAt: Date, readonly amount: number, ) {}}
class Order extends AggregateRoot { private paid = false;
private constructor( id: string, readonly amount: number, ) { super(id); }
static place(id: string, amount: number): Order { return new Order(id, amount); }
get isPaid(): boolean { return this.paid; }
pay(at: Date): void { if (this.paid) { throw OrderError.alreadyPaid(this.id); }
this.paid = true; this.record(new OrderPaid(this.id, at, this.amount)); }}
// The builder: valid defaults once, then methods named in the ubiquitous language.const at = new Date('2026-03-01T10:00:00.000Z');
interface OrderProps { readonly id: string; readonly amount: number; readonly paid: boolean;}
class OrderBuilder extends Builder<OrderProps, Order> { protected defaults(): Defaults<OrderProps> { return { id: sequence('order'), amount: 30, paid: false }; }
protected construct(props: OrderProps): Order { const order = Order.place(props.id, props.amount);
if (props.paid) { order.pay(at); }
return order; }
paid(): this { return this.with('paid', true); }}
const anOrder = (): OrderBuilder => new OrderBuilder();
// The specs: one intention, then the facts it recorded or the refusal.beforeEach(() => { resetSequences();});
it('records the payment of a placed order', () => { given(anOrder()) .when((order) => order.pay(at)) .thenEvents([{ name: 'ordering.order.paid', amount: 30 }]);});
it('refuses to pay an order twice', () => { given(anOrder().paid()) .when((order) => order.pay(at)) .thenError('ORDER_ALREADY_PAID', { orderId: 'order-1' }) .thenState((order) => expect(order.isPaid).toBe(true));});No database, no container, no mock: the second test reads "given a paid order, when it is paid,
then it is refused with ORDER_ALREADY_PAID, and it is still paid". The sections below take
each piece in turn.
Builders
Section titled “Builders”A builder declares valid defaults once; a test states only what matters to the rule it checks. Every step returns a new, immutable builder, so a base builder can be shared between tests.
For an aggregate, write a class: its methods are named in the ubiquitous language, and
construct goes through the public API, so a test can never start from an invalid state.
import { Builder, sequence, type Buildable, type Defaults } from '@ddd-ts-kit/testing';
interface OrderProps { id: string; customer: Customer; lines: readonly Line[]; paid: boolean;}
class OrderBuilder extends Builder<OrderProps, Order> { protected defaults(): Defaults<OrderProps> { return { id: sequence('order'), customer: aCustomer(), lines: [aLine()], paid: false }; }
protected construct(props: OrderProps): Order { const order = Order.place(props.id, props.customer, props.lines); if (props.paid) { order.pay(); } return order; }
placedBy(customer: Buildable<Customer>): this { return this.with('customer', customer); }
withLine(line: Buildable<Line>): this { return this.adding('lines', line); }
paid(): this { return this.with('paid', true); }}
export const anOrder = (): OrderBuilder => new OrderBuilder();const order = anOrder().paid().withLine(aLine().with('sku', 'sku-1')).build();const orders = anOrder().placedBy(aCustomer().named('Ada')).buildMany(3);For plain data (payloads, DTOs, simple value objects) the functional form is enough. It gives
the generic with(key, value) and withMany(partial), both checked against the properties:
import { defineBuilder, lazy, sequence } from '@ddd-ts-kit/testing';
export const aLine = defineBuilder<Line>({ defaults: { sku: sequence('sku'), quantity: 1 },});
export const anEmail = defineBuilder<{ value: string }, Email>({ defaults: { value: sequence('email', (n) => `user${n}@example.com`) }, build: ({ value }) => Email.create(value),});
aLine().with('quantity', 3).build(); // { sku: 'sku-1', quantity: 3 }- A builder is accepted wherever a value is:
.with('customer', aCustomer().named('Ada')), and inside arrays:.with('lines', [aLine(), { sku: 'pen', quantity: 2 }]). sequence('order')yieldsorder-1,order-2... at each build;lazy(() => new Date())computes a default at each build. A value given withwithconsumes nothing.- Call
resetSequences()inbeforeEach, so a test does not depend on the ones before it. - The class form keeps
with,withManyandaddingprotected on purpose: the public surface of an aggregate builder is the language of the domain, not its property names.
Instead of creating the whole object in every test, where the one value the rule depends on is
lost among the others, and where a new argument of Order.place breaks every test:
const order = Order.place('order-1', { id: 'customer-1', name: 'Grace Hopper' }, [{ sku: 'sku-1', quantity: 1 }]);order.pay();write what the test is about, and let the builder hold the rest in one place:
const order = anOrder().paid().build();Generated data
Section titled “Generated data”npm i -D @faker-js/fakerimport { defineBuilder, sequence } from '@ddd-ts-kit/testing';import { generated } from '@ddd-ts-kit/testing/faker';
// From the domain of the context.interface CustomerProps { readonly id: string; readonly name: string; readonly email: string; readonly birthDate: Date;}
declare class Customer { static register(props: CustomerProps): Customer;}
export const aCustomer = defineBuilder<CustomerProps, Customer>({ defaults: { id: sequence('customer'), name: generated((faker) => faker.person.fullName()), email: generated((faker) => faker.internet.email().toLowerCase()), birthDate: generated((faker) => faker.date.birthdate({ mode: 'age', min: 18, max: 90 })), }, build: (props) => Customer.register(props),});
aCustomer().build(); // a different, valid customer at each buildaCustomer().with('email', 'ada@example.com'); // stated: the rule under test depends on itThe defaults are generated yet valid: each generator is constrained to what the domain accepts
(an adult, a lower-case address), and build still goes through Customer.register.
The rule: generated data for the fields a test does not care about, explicit values for what the rule under test depends on. A test never asserts a generated value it did not state, and never relies on a generated value falling on the right side of a rule.
Instead of leaving the value the rule is about to the generator, so that the test passes because of a default nobody reads:
it('lets an adult buy wine', () => { const customer = aCustomer().build(); // adult because the default says so today expect(customer.mayBuy(wine, today)).toBe(true);});state it, with a date the reader can check:
it('lets an adult buy wine', () => { const customer = aCustomer().with('birthDate', new Date('2008-05-01')).build(); expect(customer.mayBuy(wine, new Date('2026-05-01'))).toBe(true); // 18 on that day});Every generated value is drawn from one generator per test file, dataGenerator(). Its seed
comes from TEST_SEED, else it is random, and it is always known (dataGenerator().seed):
// vitest setup fileimport { beforeEach, onTestFailed } from 'vitest';import { reportSeedOnFailure } from '@ddd-ts-kit/testing/faker';
reportSeedOnFailure({ beforeEach, onTestFailed });// under a failed test: Generated data seed: 1837264. Replay this file with TEST_SEED=1837264.| Call | Does |
|---|---|
generated((faker) => value) |
a default or a with value drawn at each build |
dataGenerator() |
the current generator: seed, faker, restart() |
createDataGenerator({ seed?, locales?, referenceDate? }) |
a generator of its own; relative dates are computed from a fixed referenceDate, so a seed replays them too |
useDataGenerator(generator) |
makes generated draw from it, for instance a fixed seed in one suite |
reportSeedOnFailure(runner, log?) |
prints the seed under each failed test |
Scenarios
Section titled “Scenarios”import { given, scenario } from '@ddd-ts-kit/testing';
it('records the payment of a placed order', () => { given(anOrder().withLine(aLine().with('quantity', 2))) .when((order) => order.pay()) .thenEvents([{ name: 'ordering.order.paid', amount: 30 }]);});
it('refuses to pay an order twice', () => { given(anOrder().paid()) .when((order) => order.pay()) .thenError('ORDER_ALREADY_PAID') .thenState((order) => expect(order.status).toBe('paid'));});given takes an aggregate or its builder and discards the events of the arrangement
(pullEvents()), so only the events of when are asserted. scenario(() => aggregate) is
the same with the arrangement as a function, replayed from a fresh aggregate at each when.
| Assertion | Passes when |
|---|---|
thenEvents([...]) |
exactly these events, in order; each described by its name, its class, or the properties that matter |
thenEventNames([...]) |
exactly these names, in order |
thenNoEvent() |
nothing was recorded |
thenError(code), thenError(code, facts) |
the action refused with that code, whatever the class of the error, carrying these facts when given (it may carry others) |
thenError(ErrorClass, code?) |
the action threw an instance of that class: for an error that is one class, as 0.2 wrote them |
thenState((aggregate) => ...) |
your own assertions on the aggregate pass |
Failures are AssertionErrors of node:assert with the expected and recorded events side by
side, so they read well under any runner. An error raised by when is never swallowed: every
assertion but thenError fails on it. When when (or the arrangement) is asynchronous, the
chain is a promise: await given(order).when(async (o) => ...).thenEventNames([...]).
An event-sourced aggregate
Section titled “An event-sourced aggregate”givenEvents(rehydrate, events) rebuilds the aggregate by replaying its history, without a
database, and the scenario goes on as above: one command, then the events it recorded or the
refusal.
givenEvents((id, history) => AttendanceRecord.rehydrate(id, history), [opened, noShowNoted, noShowNoted]) .when((record) => record.noteNoShow('appointment-9', now)) .thenEvents([NoShowNoted, AttendanceRecordBarred]);expectSnapshotEquivalence({ events, rehydrate, policy, state }) proves the snapshots of an
aggregate: for every version of the history, the aggregate rebuilt from the snapshot its policy
would store and the events after it has the state and the version of the full replay.
Process steps
Section titled “Process steps”import { givenProcess } from '@ddd-ts-kit/testing';
givenProcess<DeadlineState>(undefined) .when((current) => deadlines.requested(booked, current)) .thenState({ appointmentId: 'appointment-1', confirmBefore: '2026-03-01T12:00:00.000Z' }) .thenTimeouts([{ name: 'deadline', at: deadline }]) .thenCommands([]);A step of a process manager is a function of its state and of what happened: givenProcess(state)
(or undefined for an instance that does not exist) runs it and asserts its decision without a
store, a queue or a bus. thenIgnored(), thenState(state), thenCommands([...]) (each by its
class or by the properties that matter), thenTimeouts([...]), thenCancelled([...]) and
thenEnded() chain; a failure is an AssertionError that says what differs.
Refusals
Section titled “Refusals”A business refusal is known by its code, and carries its facts as data: a test asserts both, never a message and never the class of one refusal.
import { expectRefusal } from '@ddd-ts-kit/testing';
expectRefusal(() => calendar.slotFor('consultation', startsAt, now), 'BOOKING_TOO_LATE', { notice: 120 });expectRefusal(Email.create('ada@'), 'INVALID_EMAIL');await expectRefusal(() => commands.execute(command), 'SLOT_TAKEN', { calendarId: 'calendar-1' });expectRefusal(subject, code, facts?) reads an action, which it runs, a Result, or an error
already caught. An action that returns a promise gives a promise to await. It answers the
error, and fails with an AssertionError that names what was refused instead, or the facts
that differ, one line each. The facts given must be carried with those values; the refusal may
carry others.
Instead of asserting the wording of a message, which breaks when it is rephrased, or the class of the error, which says nothing about which refusal it was:
expect(() => order.pay(at)).toThrow('Order order-1 is already paid.');expect(() => order.pay(at)).toThrow(OrderError);assert the code, stable for every client, and the facts the refusal carries:
import { ConceptError } from '@ddd-ts-kit/kernel';import { expectRefusal } from '@ddd-ts-kit/testing';
class OrderError extends ConceptError { static alreadyPaid(orderId: string) { return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId }); }}
const order = { id: 'order-1', pay(): void { throw OrderError.alreadyPaid(this.id); },};
expectRefusal(() => order.pay(), 'ORDER_ALREADY_PAID', { orderId: 'order-1' });Handlers
Section titled “Handlers”import { createTestBuses, InMemoryRepository, snapshots } from '@ddd-ts-kit/testing';
// domain/repositories/order.repository.ts: the port is an abstract classexport abstract class OrderRepository extends Repository<Order> {}
// test/fakes/in-memory-order.repository.ts: the fake implements the port, it never extends itclass InMemoryOrderRepository extends InMemoryRepository<Order> implements OrderRepository { constructor() { super(snapshots({ toSnapshot: (order) => order.toSnapshot(), fromSnapshot: Order.rehydrate })); }}
it('places an order and announces it', async () => { const { commandBus, domainEvents, dispatching, eventBus, clock, ids, publishedEvents } = createTestBuses(); const orders = dispatching(new InMemoryOrderRepository()); const publisher = new OrderPlacedPublisher(eventBus, ids);
domainEvents.on('ordering.order.placed', (event) => publisher.handle(event)); commandBus.register(PlaceOrder, new PlaceOrderHandler(orders, ids, clock));
const id = await commandBus.execute(new PlaceOrder('sku-1'));
expect(orders.count()).toBe(1); expect(publishedEvents('ordering.order.placed')).toMatchObject([{ payload: { orderId: id } }]);});The handler only saves: await this.orders.save(order); return order.id;. dispatching(fake)
gives the fake what the application gives every repository: once save (or any method handed
the aggregate) resolved, the events the aggregate recorded are handed over to domainEvents.
A handler spec has the production timing. commandBus runs every command in unitOfWork
(an InMemoryUnitOfWork), as the bus of the application does: the events of every save wait
until the command handler has returned, then are dispatched in recording order, and
execute resolves once the handlers of both phases ran. A handler that fails in the
before-commit phase rejects execute; nothing is undone, since there is no transaction in
memory. Called outside a command, orders.save(order) dispatches before it resolves.
domainEvents.on('ordering.order.placed', (event) => board.refresh(event.aggregateId), { phase: 'after-commit' });
await commandBus.execute(new PlaceOrder('sku-1')); // resolves even when the handler above failed
expect(domainEvents.afterCommitFailures).toEqual([]); // { error, event } of each failure, the last hundredWith createTestApplication of @ddd-ts-kit/di there is nothing to call: the fake given for a
repository port announces to app.domainEvents with the same timing, and the declared domain
event handlers react, each in its phase.
InMemoryRepository is a fake, not a mock: a working repository over a Map that keeps its own
copy of what is saved (through snapshots({ toSnapshot, fromSnapshot }) or { clone }) and
enforces optimistic concurrency on version, raising ConcurrencyError (code
CONCURRENCY_CONFLICT). Assert its state with all() and count().
A fake, not a mock
Section titled “A fake, not a mock”Instead of a mock, which checks the calls the handler made and nothing of their effect:
const orders = { findById: vi.fn(), save: vi.fn() };await new PlaceOrderHandler(orders, ids, clock).execute(new PlaceOrder('sku-1'));expect(orders.save).toHaveBeenCalledTimes(1); // still green if the order could never be read backrun the real handler through the bus on a fake, and assert what is true afterwards:
import { AggregateRoot, Command, Repository, type Clock, type CommandHandler, type IdGenerator } from '@ddd-ts-kit/kernel';import { createTestBuses, InMemoryRepository, snapshots } from '@ddd-ts-kit/testing';import { expect, it } from 'vitest';
// From the domain of the context.interface OrderSnapshot { readonly id: string; readonly sku: string;}
declare class Order extends AggregateRoot { static place(id: string, sku: string, at: Date): Order; static rehydrate(snapshot: OrderSnapshot): Order; toSnapshot(): OrderSnapshot;}
abstract class OrderRepository extends Repository<Order> {}
// From the application layer: the use case under test.class PlaceOrder extends Command<string> { constructor(readonly sku: string) { super(); }}
class PlaceOrderHandler implements CommandHandler<PlaceOrder> { constructor( private readonly orders: OrderRepository, private readonly ids: IdGenerator, private readonly clock: Clock, ) {}
async execute(command: PlaceOrder): Promise<string> { const order = Order.place(this.ids.next(), command.sku, this.clock.now());
await this.orders.save(order);
return order.id; }}
// From test/fakes/: a working repository, held to the same contract suite as the adapter.class InMemoryOrderRepository extends InMemoryRepository<Order> implements OrderRepository { constructor() { super(snapshots({ toSnapshot: (order) => order.toSnapshot(), fromSnapshot: Order.rehydrate })); }}
it('places an order', async () => { const { commandBus, dispatching, domainEvents, ids, clock } = createTestBuses(); const orders = dispatching(new InMemoryOrderRepository()); const heard: string[] = [];
domainEvents.on('ordering.order.placed', async (event) => void heard.push(event.aggregateId)); commandBus.register(PlaceOrder, new PlaceOrderHandler(orders, ids, clock));
const id = await commandBus.execute(new PlaceOrder('sku-1'));
expect((await orders.findById(id))?.toSnapshot()).toEqual({ id, sku: 'sku-1' }); expect(heard).toEqual([id]);});The mock breaks when the handler calls another method of the port for the same effect, and stays
green when the saved order cannot be found again; the fake does neither. The
test/no-mocks rule reports vi.fn,
vi.mock and their kin in the tests of a context.
Who is acting
Section titled “Who is acting”createTestBuses() also gives executionScope and actorContext, the two doubles of the
kernel kept together, for the fakes and adapters that stamp audit columns or scope by tenant.
The default is the user test-user, correlation test-correlation, origin test.
import { aSession, anExecutionContext, createTestBuses, scoped } from '@ddd-ts-kit/testing';
const { executionScope } = createTestBuses({ executionContext: anExecutionContext().as(ada).build() });const orders = new DrizzleOrderRepository(database, executionScope, clock);
await orders.save(order); // stamped by ada, in her tenantawait scoped(executionScope, anExecutionContext().as(bob).ofTenant('globex'), () => orders.findById(order.id));| Builder | Default | Says |
|---|---|---|
aSession() |
a user of its own, opened with a password at the test epoch, never expiring | of(actor), identifiedBy, issuedAt, expiringAt, authenticatedBy, withSecondFactor, impersonatedBy, inLocale, inTimeZone, withExtensions |
anExecutionContext() |
the work of a user of its own, origin test, correlation of its own |
as(actor), inSession(session), ofTenant, elevatedOver(tenants, reason?), correlatedBy, causedBy, from(origin), dueBy, inLocale, withExtensions |
Both are classes on Builder, immutable like every builder, and accept a builder wherever an
actor or a session is expected (aSession().of(anActor().withRole('accountant')) with the
actor builder of @ddd-ts-kit/auth/testing). scoped(scope, context, work) runs a block in a
context given as a value or as a builder, with any ExecutionScope; asTenant(scope, 'acme', work) runs it as a member of a tenant, and elevatedOver('all') builds the context of platform
staff under an elevation, without the audit entry acrossTenants writes.
End to end
Section titled “End to end”The whole application, real adapters included, driven from the outside: requests in, responses
and integration events out. Nothing here knows the HTTP framework or how the application is
composed; start says it.
import { createServer, type RequestListener } from 'node:http';import { listenOnRandomPort, startTestServer } from '@ddd-ts-kit/testing/e2e';import { afterAll } from 'vitest';
// From the project: its composition root, its HTTP framework, a token of the test identity provider.declare function composeApplication(): object;declare function expressApp(app: object): RequestListener;declare const testToken: string;
const app = composeApplication(); // the composition root of main.tsconst server = await startTestServer({ start: () => listenOnRandomPort(createServer(expressApp(app))), // Fastify: async () => ({ url: await fastify.listen({ port: 0 }), close: () => fastify.close() }) headers: { authorization: `Bearer ${testToken}` },});afterAll(() => server.close());| Call | Does |
|---|---|
createHttpClient(baseUrl) |
the client alone, for a server the project starts itself: what the test/e2e/support/start-application.ts of a generated project uses, on its own composeApplication(persistence, options) |
client.get / post / put / patch / delete |
JSON in, { status, headers, body } out; body is parsed when the content type is JSON, text otherwise; a 4xx or 5xx is a response, not an exception |
client.post(path, body, { headers }), client.withHeaders(headers) |
headers for one call, or a client for another caller |
expectStatus(response, 201) |
hands the response back, or fails showing the body, where the API says why |
expectProblem(response, { status, code, facts }) |
asserts an RFC 9457 application/problem+json answer and returns the document; code and facts are optional, and facts names the facts the document must expose, as JSON writes them (a date is its ISO text) |
eventually(assertion, { timeoutMs = 2000, intervalMs = 20 }) |
retries until the assertion passes; when the time is up, fails quoting the last error |
collectEvents(subscriber, types) |
records published integration events: received(type?), waitFor(type, predicate?, options?), stop() |
The assertions are those of node:assert: they read the same under any runner.
Arrange through the API. An end-to-end test creates its state the way a client would, never by writing rows. The builder that gives the aggregate to unit tests gives the request payload here, from the same properties and the same vocabulary:
class OrderBuilder extends Builder<OrderProps, Order> { // defaults(), construct() and the methods of the ubiquitous language, as above
buildRequest(): PlaceOrderRequest { const { customer, lines } = this.props(); // the defaults and what the test stated, resolved return { customerId: customer.id, lines }; }}
const { body: order } = expectStatus(await client.post<OrderView>('/orders', anOrder().withLine(aLine()).buildRequest()), 201);Reset between tests. The harness names the contract and leaves the implementation to the project and its driver:
import type { DatabaseReset } from '@ddd-ts-kit/testing/e2e';import { beforeEach } from 'vitest';
// PostgreSQL, with any client exposing `query`: every table of the application schemas.export function truncateAllTables(db: { query(sql: string): Promise<{ rows: { name: string }[] }> }, schemas: string[]): DatabaseReset { return { async reset() { const { rows } = await db.query( `select quote_ident(schemaname) || '.' || quote_ident(tablename) as name from pg_tables where schemaname in (${schemas.map((schema) => `'${schema}'`).join(', ')})`, ); if (rows.length > 0) { await db.query(`truncate ${rows.map((row) => row.name).join(', ')} restart identity cascade`); } }, };}
declare const database: DatabaseReset; // truncateAllTables(pool, ['ordering', 'billing']), made once
beforeEach(() => database.reset());Three levels with the same vocabulary
Section titled “Three levels with the same vocabulary”One rule, "an order that is already paid cannot be paid again", at three of the four test levels (the adapter level is the contract suites, below). The builder is the same; only what runs underneath changes.
// 1. Domain: the aggregate alone. Milliseconds, no double.it('refuses to pay an order twice', () => { given(anOrder().paid()) .when((order) => order.pay(at)) .thenError('ORDER_ALREADY_PAID');});
// 2. Application: the real handler through the bus, fakes for the ports.it('refuses to pay an order twice', async () => { const orders = new InMemoryOrderRepository(); const app = createTestApplication({ modules: [orderingModule], fakes: [fake(OrderRepository, orders)] }); const order = anOrder().paid().build(); await orders.save(order);
await expect(app.commandBus.execute(new PayOrder(order.id))).rejects.toMatchObject({ code: 'ORDER_ALREADY_PAID' }); expect(app.eventBus.published).toEqual([]);});
// 3. End to end: HTTP, the real database and broker, generated data, state made through the API.it('refuses to pay an order twice', async () => { const events = collectEvents(broker, [OrderPaid]); const { body: order } = expectStatus(await client.post<OrderView>('/orders', anOrder().buildRequest()), 201); expectStatus(await client.post(`/orders/${order.id}/payment`), 204);
expectProblem(await client.post(`/orders/${order.id}/payment`), { status: 409, code: 'ORDER_ALREADY_PAID' });
await events.waitFor(OrderPaid, (event) => event.payload.orderId === order.id); await eventually(async () => expect((await client.get<OrderView>(`/orders/${order.id}`)).body.status).toBe('paid')); expect(events.received(OrderPaid)).toHaveLength(1);});Level 2 uses createTestApplication and fake of @ddd-ts-kit/di: the port, an abstract
class, is what the fake is bound under. With another container,
createTestBuses() above gives the same buses to register the handler on. At level 3 the
customer of anOrder() is generated, the order line is whatever the default says, and the
only values the test states are the ones the rule depends on: the order is paid, then paid
again.
Contract suites
Section titled “Contract suites”A fake is only worth trusting if it behaves like the adapter it stands for. Each suite takes the runner's functions and a factory, and is run against both.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';import type { AggregateRoot, Repository } from '@ddd-ts-kit/kernel';import type { InMemoryRepository } from '@ddd-ts-kit/testing';import { repositoryContract } from '@ddd-ts-kit/testing/contracts';
// From the context and its test folder.interface Line { readonly sku: string; readonly quantity: number;}
declare class Order extends AggregateRoot { addLine(line: Line): void; toSnapshot(): object;}
declare const anOrder: () => { build(): Order };declare const aLine: () => { build(): Line };// The fake of the Handlers section: it extends InMemoryRepository, it never extends the port.declare class InMemoryOrderRepository extends InMemoryRepository<Order> { constructor();}declare function freshDrizzleRepository(): Promise<Repository<Order>>;
const runner = { describe, it, expect, beforeEach, afterEach };const options = { createAggregate: () => anOrder().build(), mutate: (order: Order) => order.addLine(aLine().build()), idOf: (order: Order) => order.id, stateOf: (order: Order) => order.toSnapshot(),};
repositoryContract(runner, { ...options, name: 'InMemoryOrderRepository', createRepository: () => new InMemoryOrderRepository() });repositoryContract(runner, { ...options, name: 'DrizzleOrderRepository', createRepository: () => freshDrizzleRepository() });| Suite | Holds |
|---|---|
repositoryContract |
save then find, unknown id, update, isolation of the stored copy, optimistic concurrency (any error with code CONCURRENCY_CONFLICT) |
tenantIsolationContract |
one repository under a strategy of tenant isolation: what a tenant saved is hidden from another; another tenant saving under the same id leaves it untouched, by an update or (given recreate(aggregate), a new aggregate under the same id) by an insert; platform staff elevated over every tenant write nothing, and read the other tenant's aggregate when elevatedReads is true (shared tables, row-level security); a write of work that names no tenant is refused (NoCurrentTenantError) and a read is refused or finds nothing. createRepository(scope) reads the tenant from the scope it is given, on storage where the tenants acme and globex exist. To prove row-level security itself, run it against a repository that writes no tenant condition |
eventBusContract |
any EventBus provider: routing by type, several subscribers, unsubscribe, exact envelope round trip (traceparent, correlationId, tenantId), handler failure surfaces. Redeliveries are accepted; return settle() from createBus for providers that deliver asynchronously |
unitOfWorkContract |
any UnitOfWork, called through run without a command bus: domain events wait for the end and are dispatched before the commit, so handlers see every save and what they write, the outbox included, commits with it; a failure in a save, in the work, in a handler or in the commit (a deferred constraint) leaves nothing behind; after-commit handlers run once the rows are committed, never otherwise. An adapter not built on withinUnitOfWork fails here |
outboxStoreContract |
any outbox store: order, limit, lease, concurrent claims, published, failed, dead, retryDead, purge; with carriesTenant: true, the tenant of each event kept |
processedEventStoreContract |
any ProcessedEventStore: once per consumer and event, also under concurrent deliveries |
eventStoreContract |
any EventStore: a stream created at version 0 and read back in order, numbered from 1, with its type, shape version, data, metadata and instant; an append at a version the stream no longer has (behind or ahead) refused with CONCURRENCY_CONFLICT and nothing kept of it; one of two appends at the same version through; streams numbered apart; the whole store read in the order of the appends, positions rising from 1, after a position and within a limit; head() |
eventStoreUnitOfWorkContract |
a transactional EventStore with the outbox of its database under one UnitOfWork: the stream and the outbox records published for its events commit together; nothing left in the outbox for an append rolled back, nor for one refused by a concurrency conflict. createSubject() answers { unitOfWork, store, outbox } |
processStoreContract |
any ProcessStore of process managers: an instance as saved (JSON state, armed timeouts, end), a version that is not the next one refused with CONCURRENCY_CONFLICT, one of two first saves winning, and with unitOfWork nothing left by a rolled back save |
jobStoreContract |
any JobStore of background jobs: due jobs earliest first and each to one claim, delayed jobs, idempotency keys scoped by tenant (two tenants never share one), the tenant and the subject find shows, the lease and its heartbeats, reports heard only from the attempt that holds the job (progress, checkpoint handed to the next attempt, success, retry, dead and revived, release), cancellation of a waiting and of a running job, purge. With unitOfWork (over the same context as the store just created), a job added, cancelled or revived in a unit of work that rolls back is left as it was, and one that commits is added, cancelled or revived. The claims ask for a lease of 30 s |
cacheContract |
any Cache: round trip of every shape of value, isolation of entries, deletion, time to live |
cachePurgerContract |
any CachePurger with its shared cache (store, holds): a tag purges what it names and nothing else, a kind reaches each element, the tenants stay apart, purging is idempotent |
lockContract |
any Lock: one holder at a time also under concurrent requests, release, expiry, a late release ignored, growing fencing token, waitMs |
jobSchedulerContract |
any JobScheduler: registration and description, a duplicate name and an unreadable schedule refused, runNow (also for a disabled job, an unknown name rejected), nothing by time before start or after stop, no overlap by default, a failing job answered failed with its code while the others go on, the execution context of a run |
loggerContract |
any Logger: every level with its message and fields of every shape, level and message that no field forges, children that add their fields and nest, fields read at each entry, an entry below the level dropped without reading a field, isLevelEnabled answering what is written (for a child too), no throw on a circular field or a getter that throws, at the top of the fields included; an Error passed as a field written as its description (type, code of a DomainError, message, own stack, cause, eight errors deep at most), its message replaced when the logger scrubs unless it is a DomainError; every entry in the sink once flush() resolves, then no throw from a log call or a second flush. createLogger({ level, fields, scrubMessages }) answers the logger and lines(), what its sink received as { level, message, fields }. No capability flag: InMemoryLogger takes the same three options and passes the whole suite |
criteriaContract |
any translator of criteria, the in-memory evaluator included: a table of criteria and the ids each answers, in order, over eight fixed rows. Every operator on every type it applies to, decimals as numbers, instants to the millisecond and across zones, case and the characters a LIKE would trip on, nulls under each operator, and, or, not and what a negation does to a null, sorts with ties and nulls in both directions, offset pages with their total, every sort walked by cursor one and two rows at a time, the four kinds of access scope, and hostile values. createReadModel(rows) answers { search(scopedCriteria) } over exactly those rows, read with criteriaContractDefinition |
readModelStoreContract |
any ReadModelTable under TableReadModelStore: rows of every type round trip, the current row handed to a later event, an event applied once, never one older than the row, order by event id at the same instant and by version when given, concurrent writes ending on the latest, deletions that last, listing by criteria and scope, and ProjectionRebuild: rebuilt from the source alone, no event lost while it exports (on a row already copied and on one not yet), stale and new events after the swap, deletions during and before it, a failed export leaving the table as it was, the table still itself after two rebuilds. createTable() answers { table, unitOfWork }; SourceOfTruth is the fake of the source |
fileStorageContract |
any FileStorage: content round trip with size, media type and checksum, several mebibytes streamed, a write beyond maxBytes refused leaving the previous content, keys that could leave their place refused, deletion, copy, move, listing by prefix, signed download and upload links that expire, refuse a changed signature or another media type, and are never signed for more than an hour, a download served as an attachment even without a name. follow sends a request to a link: fetch by default, followServed(storage) for a storage the application serves |
rateLimiterContract |
any RateLimiter: limit, remaining and retryAfterMs, refused calls not counted, sliding window, isolation of keys, concurrent calls |
The cache, lock, rate limiter and file storage suites also take elapse(ms), which makes time
pass for the subject: advance the clock it reads, or really wait when the time is a server's. The job
scheduler suite takes it from createScheduler, with the scheduler and the scope it runs its
jobs in: { scheduler, scope, elapse }. It asks for a few seconds at most, with jobs of every
second.
The unit of work suite takes createUnitOfWork, called before each test, which answers a
UnitOfWorkUnderContract over an empty table and an empty outbox:
{ unitOfWork, outbox, insert(id), insertRefusedAtCommit(id), ids(), committedIds?(), close?() }.
The table holds an identifier under a primary key and a reference to a row of the same table
that the database checks at commit only (a deferred constraint): insertRefusedAtCommit writes
a row the commit refuses. Give committedIds, what another connection sees, where the
database serves several connections: the suite then also proves that the rows of a
before-commit handler are invisible from outside the transaction and visible to the
after-commit handlers.
The suites use only describe, it, beforeEach, expect(...).toBe and toEqual
(afterEach is optional), which the globals of vitest and jest both provide: that part of a
runner is the ContractRunner type.
A package that holds a port of its own publishes its contract on the same base, ContractSuite:
a subject created anew before each test, and it, expect, afterEach bound to the runner.
import { ContractSuite, type Awaitable, type ContractRunner } from '@ddd-ts-kit/testing/contracts';
// The port the package holds.export interface Geocoder { locate(address: string): Promise<{ readonly latitude: number; readonly longitude: number } | undefined>;}
export function geocoderContract(runner: ContractRunner, options: { name: string; create(): Awaitable<Geocoder> }): void { new GeocoderSuite(runner, () => options.create()).describe(`${options.name} (geocoder contract)`);}
class GeocoderSuite extends ContractSuite<Geocoder> { protected tests(): void { this.it('answers undefined for an address it does not know', async () => { this.expect(await this.subject.locate('nowhere')).toBe(undefined); }); }}Secrets an adapter must not show
Section titled “Secrets an adapter must not show”An adapter is logged, spread into a diagnostic and serialised by code that knows nothing of
what it holds. expectNoSecret(adapter, [password, url]) asserts that none of the given values
shows through JSON.stringify, an object spread, the values Object.keys leads to, or
util.inspect at any depth, hidden properties included. The failure names the view and the
rank of the secret, never its value.
import { KafkaEventBus } from '@ddd-ts-kit/kafka';import { expectNoSecret } from '@ddd-ts-kit/testing';import { it } from 'vitest';
declare const brokers: string;declare const password: string;
it('shows no credential', () => { const bus = new KafkaEventBus({ brokers, client: { 'sasl.password': password } });
expectNoSecret(bus, [password]);});Standard decorators under Vitest
Section titled “Standard decorators under Vitest”Vitest 5 (Vite 8, Oxc) lowers legacy decorators and does not lower standard (TC39) ones: a
spec that imports a .ts file holding @CommandHandler(...) or @Retry(...) stops on a
syntax error. decorators() closes that gap, and only that one.
import { defineConfig } from 'vitest/config';import { decorators } from '@ddd-ts-kit/testing/vitest';
export default defineConfig({ plugins: [decorators()] });- What it does. A
.ts,.mtsor.ctsfile with a line that starts with a decorator is compiled by the TypeScript compiler of the project before the runner reads it; every other file is left to the runner. Imports are kept as written, for the runner to resolve. - Which options it follows.
target,useDefineForClassFields,verbatimModuleSyntaxandexperimentalDecorators, read from thetsconfig.jsonnearest to each file (the one the runner follows too), or from thetsconfigoption. A target ofESNextwould leave the decorators in place, soES2024stands in for it. - It steps aside in the legacy mode. Under
experimentalDecorators: trueit answers nothing and the runner lowers the decorators itself. A project on class-validator or NestJS may keep the plugin in its config or drop it: the result is the same. - Source maps. The map names the real file and holds its text: stack traces, breakpoints and coverage point at the source.
- Cache. One entry per file content, compiler and options, under the cache directory of
Vite (
cacheDiroption to move it). A file is compiled once, then read back: about 60 ms with the native TypeScript 7 and 220 ms with TypeScript 6 the first time, nothing after. - Which compiler. The
tscof thetypescriptpackage the project installs, from 5.6, the native TypeScript 7 included: it runs the compiler binary, not its JavaScript API, which the native compiler does not have.compilernames another package or a path.
| Option | Default |
|---|---|
tsconfig |
the nearest tsconfig.json above each file |
compiler |
typescript |
cacheDir |
ddd-ts-kit-decorators in the cache directory of Vite |
It does not type-check (the build does), and it does not touch .tsx.
Coverage thresholds read from the config
Section titled “Coverage thresholds read from the config”@ddd-ts-kit/testing/coverage gives the test runner what it measures and the floor it is held
to, read from the coverage key of ddd-ts-kit.config.json (the reference when the key is
absent), so that no threshold is written in a runner config:
import { governedCoverage } from '@ddd-ts-kit/testing/coverage';import { defineConfig } from 'vitest/config';
export default defineConfig(({ mode }) => ({ test: { coverage: { provider: 'v8', reporter: ['text', 'lcov'], ...governedCoverage(mode) } },}));include: the layers of every context the run executes, the files no spec loads included.--mode unitmeasuresdomain,applicationandpresentation,--mode integrationmeasuresinfrastructure, any other mode the four.thresholds: one entry per context and layer that holds a source file, so a context cannot hide behind another and an empty layer is held to nothing until its first file.- A partial run measures and holds only the contexts named in
DDD_TS_KIT_AFFECTED(comma separated, asddd-ts-kit affectedcomputes them for a pull request); unset, the run covers every context.unaffectedContexts()answers the directories of the other contexts, ending in/, for the runner to exclude their specs:exclude: unaffectedContexts().map((directory) => `${directory}**`). governedJestCoverage()gives the same ascollectCoverageFromandcoverageThreshold.REFERENCE_COVERAGEis the reference: domain 90/85/90/90, application 85/80/85/85, presentation 80/70/80/80, infrastructure 70/60/70/70 (statements, branches, functions, lines).
Under the legacy decorator mode with emitDecoratorMetadata, add decoratorCoverage() of
@ddd-ts-kit/testing/vitest to plugins: the statements the transform writes to apply
decorators hold, for every type a decorated class names, a branch no test can take; the plugin
marks them with the hint the v8 provider reads, as the provider does by itself for SWC.
See also
Section titled “See also”- Testing strategy: why fakes and not mocks, the four levels, what to test where
- Test runners: Vitest or Jest, and what each gives with the kit
@ddd-ts-kit/di, tests:createTestApplicationandfake, the application spec with the container@ddd-ts-kit/kernel: the aggregates, ports and in-memory adapters the builders and fakes work with