▍ aminebeihaqi
  • home
  • projects
  • blog
resume.pdf ↓

▍ amine beihaqi — built with next.js & deployed on vercel

resume.pdf
All Posts
September 22, 202619 min read

Testing a Single Service: Mocks, Fakes, and Real Dependencies_

testingunit-testingtest-doublesmicroservicestestcontainers

A service surrounded by its dependencies, some of them real and some replaced by stand-ins drawn with dashed outlines

This article is part of the series From Code to Production: A Quality Framework for Multi-Service Platforms. The earlier deep dives were about the boundaries between services. This one is about what happens inside one service, and about a decision every test makes, usually without anyone noticing: which dependencies are real, and which are stand-ins.

It uses the series’ running example. The Orders API accepts orders over HTTP, prices them, charges the customer through Payments over gRPC, stores the order in its own Postgres database and publishes OrderPlaced through a transactional outbox. It is a small service with every kind of dependency a backend service usually has: a database, another service, a message broker and the system clock.

Series status. The other deep-dive articles referenced below are still being written. Their titles appear in italics, without links, until they are published.

this article was generated by AI

I did not write this by hand. I set the topic, the scope and the direction, but the research, the example platform, the diagrams and the wording were all produced by an AI model. It is published as generated, so it may contain factual errors, outdated details or points that contradict each other between sections. Treat it as a starting point rather than a reference, and check anything you plan to act on against the sources listed at the end.

Corrections are welcome and treated as contributions. If you find something wrong, email me at corrections@beihaqi.com and I will update the article.


Three ways the tests lied

The Orders team had a large, fast, green test suite. Three production incidents showed what it was actually testing.

The mock that described a service that no longer existed. Orders’ tests replaced Payments with a mock that returned amount_cents. Payments stopped sending that field, and every order was recorded as free. The mock kept returning the old field, so every Orders test kept passing. This is the failure behind Contract Testing and Version Compatibility.

The fake database that was not Postgres. To keep tests fast, the repository tests ran against an in-memory SQLite database. Production ran Postgres. An upsert that used ON CONFLICT on a partial unique index, and a numeric column that SQLite stores with different precision, behaved differently in the two engines. The tests passed, and duplicate orders appeared in production under concurrent retries.

The tests that broke every time the code improved. The pricing logic had unit tests that mocked every collaborator and verified each call: the tax calculator was called once, with these arguments, before the discount service. A refactoring that changed the order of two calls, with identical results, broke forty tests. None of those tests had ever caught a bug. They only recorded how the code happened to be written.

These are three different mistakes, but they have one cause: the test replaced something real with something that did not behave like it, or it checked how the code worked instead of what it did.


The vocabulary

“Mock” is used loosely for every kind of stand-in, which makes these decisions harder to discuss. Gerard Meszaros’s xUnit Test Patterns gives the standard names, and Martin Fowler’s summary is the usual reference.12 The general term is test double.

KindWhat it doesExample in Orders
DummyFills a parameter and is never usedA logger passed to a constructor in a test that never logs
StubReturns canned answers to callsA Payments client that always returns “approved”
SpyA stub that also records how it was calledAn email sender that stores the messages it was asked to send
MockPre-programmed with expectations, and fails the test if the calls do not match”Expect charge to be called exactly once with order 42”
FakeA working, simplified implementation that is not suitable for productionAn in-memory orders repository, or a small in-process Payments server

Two more options sit beyond the doubles:

  • Emulator. A locally runnable version of a real system, often provided by its vendor, such as a cloud storage or queue emulator.
  • The real thing. The same database engine, broker or library used in production, running locally or in a container.

Fowler’s essay “Mocks Aren’t Stubs” draws the distinction that matters most in practice: state verification checks the result of an operation, while behavior verification checks which calls were made to produce it.3 Mocks are the tool for behavior verification. Most of the problems above come from using behavior verification where state verification would have worked.

A spectrum of test doubles from dummy to real dependency, showing that fidelity increases towards the real dependency while speed and isolation increase towards the dummy


Choosing a dependency: real first

Google’s Software Engineering at Google sets out a clear order of preference. Use the real implementation when it is fast, deterministic and has simple dependencies. If that is not feasible, the best option is often a fake. Use stubbing sparingly, and interaction testing only where it is needed.4 The same book notes that over-reliance on mocking frameworks leads directly to brittle interaction tests, which is why Google prefers real objects over mocks when the real objects are fast and deterministic.5

For the Orders service, that works out as:

DependencyChoiceWhy
Pricing and validation logicRealPure code, fast and deterministic
PostgresReal, in a containerThe SQLite fake did not behave like Postgres
PaymentsFake or stub, plus a contract testAnother team’s service, and the contract test keeps the stand-in honest
BrokerReal, in a container, for a few integration tests; outbox rows asserted directly elsewhereBroker behavior matters only at the edge
System clockFakeThe real clock is not deterministic
Email providerSpySending the email is the behavior

Shape the service so it can be tested

The choices above are only available if the code allows them. A service that creates its own database connection inside the pricing function, or calls Date.now() in the middle of business logic, gives tests nothing to substitute.

Alistair Cockburn’s ports and adapters architecture, also called hexagonal architecture, puts the business logic in the middle and every connection to the outside world behind an interface, a port, with an adapter that implements it.6 Tests can then drive the core directly and plug in whichever adapter suits the test.

In code, the ports are small interfaces, and the service is assembled from them:

export interface OrdersRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
}
 
export interface PaymentsGateway {
  charge(request: ChargeRequest): Promise<ChargeResult>;
}
 
export interface Clock {
  now(): Date;
}
 
export function createOrdersService(deps: {
  orders: OrdersRepository;
  payments: PaymentsGateway;
  outbox: EventOutbox;
  clock: Clock;
  newId: () => string;
}) {
  // business logic only: no connections, no globals
}

Production code wires in the Postgres adapter, the gRPC adapter and the system clock. Tests wire in whatever each test needs. Two rules keep this honest:

  • Ports belong to the service that uses them. PaymentsGateway describes what Orders needs from Payments, not the whole Payments API. That keeps the interface small and makes fakes easy to write.
  • Only mock types you own. Steve Freeman and Nat Pryce’s advice in Growing Object-Oriented Software, Guided by Tests is to put your own interface in front of third-party code, and replace that interface in tests, rather than mocking the third-party library directly.7 A mock of someone else’s client library encodes guesses about behavior you do not control.

Testing each layer

The core: real objects, and properties instead of examples

Business logic in the core should be tested with real objects and no doubles at all, except for the clock and ID generator. These are the fastest and most stable tests in the suite, and they are where most of the service’s rules should be verified.

Example-based tests check the cases someone thought of. Property-based tests check that a rule holds for many generated inputs, and shrink any failure to the smallest input that breaks it. The technique comes from QuickCheck, introduced by Koen Claessen and John Hughes in 2000.8 For TypeScript, fast-check provides the same approach.9

Pricing is a good candidate, because its rules are easy to state and its edge cases are hard to list:

import fc from "fast-check";
 
const line = fc.record({
  unitPriceCents: fc.integer({ min: 0, max: 1_000_000 }),
  quantity: fc.integer({ min: 1, max: 100 }),
});
 
it("the order total equals the sum of its lines", () => {
  fc.assert(
    fc.property(fc.array(line, { minLength: 1, maxLength: 50 }), (lines) => {
      const total = priceOrder(lines).totalCents;
      const expected = lines.reduce((sum, l) => sum + l.unitPriceCents * l.quantity, 0);
      expect(total).toBe(expected);
    }),
  );
});
 
it("a discount never makes the total negative", () => {
  fc.assert(
    fc.property(
      fc.array(line, { minLength: 1, maxLength: 50 }),
      fc.integer({ min: 0, max: 100 }),
      (lines, discountPercent) => {
        expect(priceOrder(lines, { discountPercent }).totalCents).toBeGreaterThanOrEqual(0);
      },
    ),
  );
});

Good properties for business logic tend to take a few forms: an invariant that always holds, a round trip such as serialize then parse, an equivalence with a simpler reference implementation, or a state machine where no sequence of valid operations reaches an invalid state.

The adapters: test against the real thing

Adapters are where the service touches something it does not control, so they are where fakes are most dangerous. The SQLite incident happened in an adapter test.

Database adapters run against the same engine and version as production. Testcontainers starts a real Postgres in a container from inside the test run, so this does not require a shared test database.10 Run the real migrations against it, so the tests also catch migrations that do not match the code.

import { PostgreSqlContainer } from "@testcontainers/postgresql";
 
let container: StartedPostgreSqlContainer;
let repo: OrdersRepository;
 
beforeAll(async () => {
  container = await new PostgreSqlContainer("postgres:16").start();
  await runMigrations(container.getConnectionUri());
  repo = createPostgresOrdersRepository(container.getConnectionUri());
});
 
afterAll(async () => {
  await container.stop();
});

Clients for other teams’ services are tested against a contract. The gRPC Payments adapter is exercised by the consumer contract test described in Contract Testing and Version Compatibility. The contract replaces “what Orders believes Payments does” with “what Payments has agreed to do”.

Clients for third-party HTTP APIs can be tested against an HTTP stub server such as WireMock, which serves recorded or scripted responses over a real socket.11 This exercises the real HTTP client, serialization, timeouts and error handling. Be aware that the recordings go stale in exactly the way mocks do, so refresh them against the real API on a schedule.

The whole service: component tests

A component test runs the whole service through its real entry point, its HTTP API, with its own database real and everything owned by other teams replaced.12 In Toby Clemson’s catalog of microservice testing strategies, this is the component test, and it is the most valuable single layer for a service like Orders.

it("places an order, charges once and records an OrderPlaced event", async () => {
  payments.approveNextCharge();
 
  const response = await http.post("/orders", {
    customerId: "7",
    lines: [{ sku: "MUG", unitPriceCents: 1299, quantity: 2 }],
  });
 
  expect(response.status).toBe(201);
  expect(await db.orders.findById(response.body.id)).toMatchObject({
    status: "paid",
    totalCents: 2598,
  });
  expect(await db.outbox.eventsFor(response.body.id)).toEqual([
    expect.objectContaining({ type: "OrderPlaced" }),
  ]);
  expect(payments.charges()).toHaveLength(1);
});

The assertions check the results: the HTTP response, the stored order, the outbox row. The one interaction check, that the customer was charged exactly once, is there because charging is itself the behavior that matters.


Keeping fakes honest

A fake is only as good as its resemblance to the real thing. An in-memory repository that silently accepts two orders with the same idempotency key, while the Postgres adapter rejects the second, will let a duplicate-order bug through every test that uses it.

The fix is to run the same test suite against the fake and the real implementation. Any behavior the real implementation has, the fake must have too, or the shared suite fails.

function describeOrdersRepository(name: string, create: () => Promise<OrdersRepository>) {
  describe(`OrdersRepository: ${name}`, () => {
    let repo: OrdersRepository;
    beforeEach(async () => {
      repo = await create();
    });
 
    it("returns null for an unknown order", async () => {
      expect(await repo.findById(orderId("missing"))).toBeNull();
    });
 
    it("rejects a second order with the same idempotency key", async () => {
      await repo.save(order({ id: "1", idempotencyKey: "k-1" }));
      await expect(repo.save(order({ id: "2", idempotencyKey: "k-1" }))).rejects.toThrow(
        DuplicateOrderError,
      );
    });
  });
}
 
describeOrdersRepository("in-memory fake", async () => createInMemoryOrdersRepository());
describeOrdersRepository("Postgres", async () => createPostgresOrdersRepository(await freshDatabase()));

The same idea applies to fakes of other services. A Payments fake should be maintained by the Payments team, or at least verified against the Payments contract, so it changes when Payments changes. Software Engineering at Google recommends that the owners of a real implementation also own its fake, precisely so the two do not drift apart.4


When a mock is the right tool

Mocks are not the enemy. They are the right tool when the interaction is the outcome:

  • A payment is charged exactly once, even when the request is retried.
  • A confirmation email is sent, with the right recipient, when an order is paid.
  • A downstream service is not called when validation fails.

In each case, there is no state inside Orders that shows the result. The only observable effect is the call. That is where behavior verification belongs.

Most other uses of mocks are a sign that the test is checking the implementation rather than the behavior. Common patterns to watch for:

PatternWhy it hurtsInstead
Verifying calls to a method that only reads dataThe test fails when a query changes, even though the result is the sameAssert on the returned result
Mocks that return mocksThe test encodes the internal object graphUse a fake, or restructure the code
Mocking value objects, such as Money or OrderReal value objects are cheap and behave correctlyUse the real object
Mocking a third-party client libraryThe mock encodes guesses about code you do not ownWrap the library in your own port, and test the adapter against the real service or a recording
A test whose setup mirrors the implementation line by lineAny refactoring breaks itTest through a public entry point

Kent Beck’s list of test desiderata names the properties being traded off: among others, tests should be behavioral, sensitive to changes in behavior, and structure-insensitive, meaning they do not change when the code’s structure does.13 The forty broken pricing tests at the start of this article failed the last of those.


Determinism: control time, randomness and identity

A test that uses the real clock, a random number generator or generated IDs produces different inputs on every run. Most of the time the result is the same. Occasionally it is not, and the test becomes flaky.

Put each source of nondeterminism behind a port, and control it in tests:

class FakeClock implements Clock {
  constructor(private current: Date) {}
  now() {
    return this.current;
  }
  advance(ms: number) {
    this.current = new Date(this.current.getTime() + ms);
  }
}
 
it("cancels an unpaid order after 30 minutes", async () => {
  const clock = new FakeClock(new Date("2026-01-15T10:00:00Z"));
  const service = createOrdersService({ ...deps, clock });
 
  const order = await service.place(unpaidOrder());
  clock.advance(30 * 60 * 1000);
  await service.expireUnpaidOrders();
 
  expect((await service.get(order.id)).status).toBe("cancelled");
});

The same applies to:

  • IDs. Inject an ID generator, so tests can predict and assert on IDs.
  • Randomness. Inject the random source, or seed it and log the seed so a failure can be reproduced. Property-based testing libraries already report the seed of a failing run.
  • Concurrency. Code that races is tested by controlling the order of operations, not by running it many times and hoping. fast-check, for example, can reorder how promises resolve to search for race conditions.9
  • Time zones and locale. Fix them in the test environment, so a test does not pass in one CI region and fail in another.

Keeping it fast

Real dependencies cost time, and a suite that takes twenty minutes will be skipped. A few practices keep the cost down without giving up fidelity:

  • Start containers once per test run, not once per test. Share one Postgres container across a test file or the whole suite, and isolate tests with data instead.
  • Isolate tests with unique data, a transaction per test, or fast truncation. Unique data per test also lets tests run in parallel against the same database. The trade-offs are covered in Test Data and Environment Parity.
  • Use a fake where a verified fake exists. The shared test suite above is what makes the in-memory repository safe to use in the hundreds of core tests that need a repository but are not testing it.
  • Keep the layers in proportion. Many core tests with no I/O, a moderate number of adapter tests against real dependencies, and a focused set of component tests for the important flows.

Where these tests run

These tests belong in the developer loop and the pull request gate of the hub’s stage-by-stage gate policy.

LayerRunsTypical durationRealReplaced
Core and property testsOn every save, and on the pull requestSecondsAll domain codeClock, ID generator
Adapter testsOn the pull requestTens of seconds to minutesPostgres, broker, HTTP stackNothing inside the adapter
Fake verification suiteOn the pull requestSeconds, plus one real runBoth the fake and the real adapterNothing
Component testsOn the pull requestMinutesThe whole service and its databaseOther teams’ services, clock
Contract testsOn the pull request, and before deploySeconds to minutesThe client adapterThe provider, by its contract

Costs and trade-offs

  • Real dependencies are slower and need infrastructure. Container-based tests need Docker, or a compatible runtime, on developer machines and CI runners, and they add start-up time. The cost is usually worth it for adapters, and usually not for core logic.
  • Fakes are code to maintain. A fake with no verification suite drifts. A verified fake needs its tests kept alongside the real adapter’s.
  • Ports and adapters add structure. For a very small service, an extra interface per dependency can be more ceremony than it is worth. The value grows with the amount of business logic and the number of dependencies.
  • Property-based tests need good properties. Finding a property that is true, meaningful and not just a copy of the implementation takes practice. A property that restates the code proves nothing.
  • Stubs of other services are still assumptions. A stub is fine inside a component test only because a contract test covers the same boundary. Without the contract test, the component test repeats the first incident in this article.

Tooling

NeedTools
Real databases and brokers in testsTestcontainers (Java, Go, .NET, Node.js, Python and others)
HTTP stub serversWireMock, MockServer, Mock Service Worker for Node.js clients
Property-based testingfast-check for TypeScript, Hypothesis for Python, jqwik for Java, QuickCheck for Haskell
Mocks and spies, when they are the right toolVitest and Jest built-ins, Mockito, unittest.mock
Contract tests for service clientsPact
Local emulators for cloud servicesVendor emulators, LocalStack

Related articles

  • From Code to Production, the hub for this series.
  • Contract Testing and Version Compatibility: what keeps a stub of another service honest.
  • Testing Async Messaging and Schema Evolution: handler tests for event consumers, using the same approach.
  • Test Data and Environment Parity: isolating data between tests that share a real database.

References

Footnotes

  1. Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code, Addison-Wesley, 2007. ↩

  2. Martin Fowler, “TestDouble”, martinfowler.com. https://martinfowler.com/bliki/TestDouble.html ↩

  3. Martin Fowler, “Mocks Aren’t Stubs”, martinfowler.com, 2007. https://martinfowler.com/articles/mocksArentStubs.html ↩

  4. Andrew Trenk, “Test Doubles”, chapter 13 of Software Engineering at Google, O’Reilly, 2020. https://abseil.io/resources/swe-book/html/ch13.html ↩ ↩2

  5. Erik Kuefler, “Unit Testing”, chapter 12 of Software Engineering at Google, O’Reilly, 2020. https://abseil.io/resources/swe-book/html/ch12.html ↩

  6. Alistair Cockburn, “Hexagonal Architecture”. https://alistair.cockburn.us/hexagonal-architecture/ ↩

  7. Steve Freeman and Nat Pryce, Growing Object-Oriented Software, Guided by Tests, Addison-Wesley, 2009. ↩

  8. Koen Claessen and John Hughes, “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs”, ICFP, 2000. ↩

  9. fast-check, property-based testing for JavaScript and TypeScript. https://github.com/dubzzz/fast-check ↩ ↩2

  10. Testcontainers. https://testcontainers.com/ ↩

  11. WireMock. https://wiremock.org/ ↩

  12. Toby Clemson, “Testing Strategies in a Microservice Architecture”, martinfowler.com, 2014. https://martinfowler.com/articles/microservice-testing/ ↩

  13. Kent Beck, “Test Desiderata”. https://testdesiderata.com/ ↩