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

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.
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.
| Kind | What it does | Example in Orders |
|---|---|---|
| Dummy | Fills a parameter and is never used | A logger passed to a constructor in a test that never logs |
| Stub | Returns canned answers to calls | A Payments client that always returns “approved” |
| Spy | A stub that also records how it was called | An email sender that stores the messages it was asked to send |
| Mock | Pre-programmed with expectations, and fails the test if the calls do not match | ”Expect charge to be called exactly once with order 42” |
| Fake | A working, simplified implementation that is not suitable for production | An 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.

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:
| Dependency | Choice | Why |
|---|---|---|
| Pricing and validation logic | Real | Pure code, fast and deterministic |
| Postgres | Real, in a container | The SQLite fake did not behave like Postgres |
| Payments | Fake or stub, plus a contract test | Another team’s service, and the contract test keeps the stand-in honest |
| Broker | Real, in a container, for a few integration tests; outbox rows asserted directly elsewhere | Broker behavior matters only at the edge |
| System clock | Fake | The real clock is not deterministic |
| Email provider | Spy | Sending 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.
PaymentsGatewaydescribes 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:
| Pattern | Why it hurts | Instead |
|---|---|---|
| Verifying calls to a method that only reads data | The test fails when a query changes, even though the result is the same | Assert on the returned result |
| Mocks that return mocks | The test encodes the internal object graph | Use a fake, or restructure the code |
Mocking value objects, such as Money or Order | Real value objects are cheap and behave correctly | Use the real object |
| Mocking a third-party client library | The mock encodes guesses about code you do not own | Wrap 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 line | Any refactoring breaks it | Test 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.
| Layer | Runs | Typical duration | Real | Replaced |
|---|---|---|---|---|
| Core and property tests | On every save, and on the pull request | Seconds | All domain code | Clock, ID generator |
| Adapter tests | On the pull request | Tens of seconds to minutes | Postgres, broker, HTTP stack | Nothing inside the adapter |
| Fake verification suite | On the pull request | Seconds, plus one real run | Both the fake and the real adapter | Nothing |
| Component tests | On the pull request | Minutes | The whole service and its database | Other teams’ services, clock |
| Contract tests | On the pull request, and before deploy | Seconds to minutes | The client adapter | The 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
| Need | Tools |
|---|---|
| Real databases and brokers in tests | Testcontainers (Java, Go, .NET, Node.js, Python and others) |
| HTTP stub servers | WireMock, MockServer, Mock Service Worker for Node.js clients |
| Property-based testing | fast-check for TypeScript, Hypothesis for Python, jqwik for Java, QuickCheck for Haskell |
| Mocks and spies, when they are the right tool | Vitest and Jest built-ins, Mockito, unittest.mock |
| Contract tests for service clients | Pact |
| Local emulators for cloud services | Vendor 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
-
Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code, Addison-Wesley, 2007. ↩
-
Martin Fowler, “TestDouble”, martinfowler.com. https://martinfowler.com/bliki/TestDouble.html ↩
-
Martin Fowler, “Mocks Aren’t Stubs”, martinfowler.com, 2007. https://martinfowler.com/articles/mocksArentStubs.html ↩
-
Andrew Trenk, “Test Doubles”, chapter 13 of Software Engineering at Google, O’Reilly, 2020. https://abseil.io/resources/swe-book/html/ch13.html ↩ ↩2
-
Erik Kuefler, “Unit Testing”, chapter 12 of Software Engineering at Google, O’Reilly, 2020. https://abseil.io/resources/swe-book/html/ch12.html ↩
-
Alistair Cockburn, “Hexagonal Architecture”. https://alistair.cockburn.us/hexagonal-architecture/ ↩
-
Steve Freeman and Nat Pryce, Growing Object-Oriented Software, Guided by Tests, Addison-Wesley, 2009. ↩
-
Koen Claessen and John Hughes, “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs”, ICFP, 2000. ↩
-
fast-check, property-based testing for JavaScript and TypeScript. https://github.com/dubzzz/fast-check ↩ ↩2
-
Testcontainers. https://testcontainers.com/ ↩
-
WireMock. https://wiremock.org/ ↩
-
Toby Clemson, “Testing Strategies in a Microservice Architecture”, martinfowler.com, 2014. https://martinfowler.com/articles/microservice-testing/ ↩
-
Kent Beck, “Test Desiderata”. https://testdesiderata.com/ ↩