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.

Author
José Morales
Published
Updated
Reading
7 min
Series · 3/3Clean Architecture without dogma
  1. 01The dependency rule, without dogma
  2. 02Ports and adapters in TypeScript
  3. 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. InMemoryOrderRepository is 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.

test/fakes/fake-payment-gateway.tsts
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.

test/application/place-order.test.tsts
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.

test/application/place-order.test.tsts
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

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

test/contracts/order-repository.contract.tsts
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.

test/contracts/postgres-order-repository.test.tsts
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.

quizpick an option

Which of these assertions checks behavior and survives an internal refactor?

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.

Written by

José Morales

Senior Backend Engineer

Backend and BFFs in production. I write about architecture, distributed systems, and the craft of writing code others can maintain.