Testing the domain and use cases without mocks
In-memory fakes instead of mocks, tests that check state instead of calls, contracts shared between adapters, and the shape of the test pyramid in Clean Architecture.
Series · 3/3Clean Architecture without dogma
- 01The dependency rule, without dogma
- 02Ports and adapters in TypeScript
- 03Testing the domain and use cases without mocks
A test that says expect(repo.save).toHaveBeenCalledTimes(2) does not prove that the order was paid. It proves that the use case called a method twice. The day someone optimizes and saves only once, the test fails even though the system works the same. With the ports from the previous article, we already have everything we need to test behavior instead of calls, without a single line of vi.fn().
Mock, stub, and fake
All three are test doubles, but each one serves a different purpose.
- A stub returns fixed answers. It keeps no state.
- A mock records calls, and the test verifies those calls. The test knows how the code does its job.
- A fake is a real, simplified implementation of the port.
InMemoryOrderRepositoryis a fake. It stores, returns what it stored, and behaves like the database where it matters.
With a fake, the test runs the use case and then looks at the resulting state. It does not care how many times save was called.
A fake for the payment provider
The in-memory repository already exists. The PaymentGateway double is missing. I write it once and every test shares it.
import type {
ChargeRequest,
ChargeResult,
PaymentGateway,
} from '../../src/application/ports/payment-gateway.js';
export class FakePaymentGateway implements PaymentGateway {
readonly charges: ChargeRequest[] = [];
private nextDecline: string | undefined;
private nextFailure: Error | undefined;
declineNextCharge(reason: string): void {
this.nextDecline = reason;
}
failNextCharge(error: Error): void {
this.nextFailure = error;
}
async charge(request: ChargeRequest): Promise<ChargeResult> {
const failure = this.nextFailure;
this.nextFailure = undefined;
if (failure) throw failure;
this.charges.push(request);
const reason = this.nextDecline;
this.nextDecline = undefined;
if (reason !== undefined) return { status: 'declined', reason };
return { status: 'approved', chargeId: `ch_${this.charges.length}` };
}
}charges records the accepted charges. The two public methods program the outcome of the next charge. The fake verifies nothing. The assertions stay in the test.
Where fakes live
InMemoryOrderRepository is in src/infrastructure/memory/ because it is also useful outside tests, for example to run the service locally without a database. FakePaymentGateway lives in test/fakes/. A provider that approves any card has no place in a real environment, and keeping it in src invites someone to wire it by mistake. If the double behaves like the real implementation, it goes in src. If it only exists to control a test, it goes in test.
From mocks to fakes
This is the same test before and after. The red lines are the mock version.
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { PlaceOrder } from '../../src/application/place-order.js';
import { InMemoryOrderRepository } from '../../src/infrastructure/memory/in-memory-order-repository.js';
import { FakePaymentGateway } from '../fakes/fake-payment-gateway.js';
describe('PlaceOrder', () => {
it('marks the order as paid when the charge is approved', async () => {
const orders = { save: vi.fn(), findById: vi.fn() };
const payments = { charge: vi.fn().mockResolvedValue({ status: 'approved', chargeId: 'ch_1' }) };
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway();
const placeOrder = new PlaceOrder(orders, payments);
await placeOrder.execute({
orderId: 'ord_1',
customerId: 'cus_1',
lines: [{ sku: 'BOOK-1', quantity: 2, unitPriceCents: 1500 }],
paymentToken: 'tok_visa',
});
expect(orders.save).toHaveBeenCalledTimes(2);
expect(payments.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3000 }));
const order = await orders.findById('ord_1');
expect(order?.status).toBe('paid');
expect(payments.charges).toMatchObject([{ reference: 'ord_1', amountCents: 3000 }]);
});
});The new version checks two things the business cares about. The order ended up paid, and the right amount was charged with the right reference. If tomorrow PlaceOrder merges the two save calls into one, this test stays green. The mock version would break.
The happy path is the least interesting one
Fakes pay off in the failure cases. The previous article decided to store the order before charging. This test makes that explicit.
it('leaves the order pending when the provider fails', async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway();
const placeOrder = new PlaceOrder(orders, payments);
payments.failNextCharge(new Error('ETIMEDOUT'));
await expect(placeOrder.execute(anOrderInput({ orderId: 'ord_2' }))).rejects.toThrow('ETIMEDOUT');
const order = await orders.findById('ord_2');
expect(order?.status).toBe('pending');
});anOrderInput is a ten-line builder that returns a valid PlaceOrderInput and accepts overrides. It keeps each test focused on the value that changes.
Running the suite
npx vitest run ✓ test/domain/order.test.ts (6 tests) 4ms ✓ test/application/place-order.test.ts (4 tests) 7ms ✓ test/contracts/in-memory-order-repository.test.ts (4 tests) 3ms Test Files 3 passed (3) Tests 14 passed (14) Duration 412ms# no Docker and no database needednpx vitest run --changed ✓ test/application/place-order.test.ts (4 tests) 7ms Test Files 1 passed (1) Tests 4 passed (4)Fourteen tests in under half a second. That speed changes how you work, because you can run the suite on every save.
deep diveA contract suite for OrderRepository
The suite is a function that receives a repository factory and registers the tests. Each adapter calls it with its own factory.
import { describe, expect, it } from 'vitest';
import type { OrderRepository } from '../../src/application/ports/order-repository.js';
import { Order } from '../../src/domain/order.js';
const anOrder = () => Order.create('ord_1', 'cus_1', [{ sku: 'BOOK-1', quantity: 1, unitPriceCents: 1500 }]);
export function orderRepositoryContract(name: string, makeRepository: () => Promise<OrderRepository>): void {
describe(`${name} satisfies OrderRepository`, () => {
it('returns the order it stored', async () => {
const repository = await makeRepository();
const order = anOrder();
await repository.save(order);
expect((await repository.findById('ord_1'))?.toSnapshot()).toEqual(order.toSnapshot());
});
it('returns undefined when the order does not exist', async () => {
const repository = await makeRepository();
expect(await repository.findById('missing')).toBeUndefined();
});
it('saving again updates the status', async () => {
const repository = await makeRepository();
const order = anOrder();
await repository.save(order);
order.markPaid('ch_1');
await repository.save(order);
expect((await repository.findById('ord_1'))?.status).toBe('paid');
});
it('does not reflect changes made after saving', async () => {
const repository = await makeRepository();
const order = anOrder();
await repository.save(order);
order.markPaid('ch_1');
expect((await repository.findById('ord_1'))?.status).toBe('pending');
});
});
}The last case is the one that catches a fake that stores references. For Postgres, the factory clears the table before each test.
import { Pool } from 'pg';
import { afterAll } from 'vitest';
import { PostgresOrderRepository } from '../../src/infrastructure/postgres/postgres-order-repository.js';
import { orderRepositoryContract } from './order-repository.contract.js';
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
afterAll(() => pool.end());
orderRepositoryContract('PostgresOrderRepository', async () => {
await pool.query('TRUNCATE orders');
return new PostgresOrderRepository(pool);
});This file runs in CI against a real database, in a service container or with Testcontainers. Locally you can exclude it from watch mode to keep the speed.
The shape of the pyramid
With this architecture the pyramid changes a little. Use case tests exercise several classes together, so they are not unit tests in the strict sense. They still run in milliseconds, because all the I/O sits behind fakes.
| Level | What it covers | Dependencies | How many |
|---|---|---|---|
| Domain | Order rules, calculations, state transitions |
None | Many |
| Use cases | PlaceOrder with all its branches |
In-memory fakes | Many |
| Contract | Each adapter against its port | Real Postgres, provider sandbox | A few per port |
| End to end | One HTTP request down to the database | The whole stack | A handful |
The wide base is domain and use cases. Contract tests are few because each port has few methods. End-to-end tests verify the composition root’s wiring, not the logic, so two or three are enough.
When I do use a mock
When the interaction is the behavior and there is no state to look at. One example is checking that an adapter sends the Idempotency-Key header to the provider. Even there I usually prefer a local HTTP server that records the requests, because it tests the real adapter.
Which of these assertions checks behavior and survives an internal refactor?
The other three describe how the use case works inside. Reading the order from the repository and checking its status describes the outcome any consumer sees. As long as that outcome does not change, the test has no reason to fail.
Summary
- Test the final state with fakes, not the sequence of calls with mocks.
- Write one fake per port and share it across all tests.
- Use the fakes to cover the failure paths, which is where the expensive bugs are.
- Run a contract suite against the fake and against the real adapter. Without it, the fake can lie.
- Keep many domain and use case tests, a few contract tests, and a handful of end-to-end tests.