I Stopped Mocking Everything in TDD
In this piece
I Stopped Mocking Everything in TDD
The Mock Hell
I once spent three hours debugging a test that was 80% mock setup.
The test passed. The code was broken in production.
The mock lied to me.
The test that wasted three hours:
// ❌ The test that wasted three hours of my life
it("should register a user", async () => {
const mockRepo = {
save: jest.fn().mockReturnValue(Promise.resolve()),
existsByEmail: jest.fn().mockReturnValue(Promise.resolve(false)),
findById: jest.fn().mockReturnValue(Promise.resolve(null)),
};
const mockEmail = {
sendWelcome: jest.fn().mockReturnValue(Promise.resolve()),
};
const mockAudit = {
log: jest.fn().mockReturnValue(Promise.resolve()),
};
const mockIdGen = {
generate: jest.fn().mockReturnValue("user-123"),
};
const service = new UserService(mockRepo, mockEmail, mockAudit, mockIdGen);
await service.register("[email protected]", 25);
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ email: "[email protected]" })
);
expect(mockEmail.sendWelcome).toHaveBeenCalled();
expect(mockAudit.log).toHaveBeenCalledWith(
"user_registered",
expect.anything()
);
});Fifteen lines of setup. Three assertions. And not a single one of them checks whether the user was actually created correctly. The test verifies that specific functions were called with specific arguments, but it says nothing about whether the behavior is right.
When I renamed save to persist, the test broke. When I added an AuditLog entry to the registration flow, the test broke. When I changed the shape of the User object passed to save, the test broke.
None of those were bugs. They were refactoring. And my tests couldn’t tell the difference.
I had built a permission slip that ripped every time I unfolded it. Tests should protect refactoring, not prevent it.
That’s when I realized I was using the wrong tool for almost every job.
The one rule: Test the outcome, not the call. If a fake can verify what happened, use the fake. Reserve mocks for the rare case where the call is the behavior — a webhook fired, an event was published, a message was sent.
A mock asks “did this method run?” A fake asks “is the world in the right state?” The first couples your test to your implementation. The second couples your test to your contract.
Test at the edge — at the boundary where your code meets a collaborator — not at the implementation. The boundary is the contract. The implementation behind it can change. Your tests survive the change because they test the contract, not the path.
Mock vs Fake: The Distinction That Matters
You skipped here from a search result, or you’re skimming because the title sounded familiar. Either way, here’s the fork in the road:
Need to verify _what happened_? → Use a fake.
Need to verify _something was called_? → Use a mock.
Need a specific return, no verification? → Use a stub.
Need a placeholder for an unused param? → Use a dummy.The rest of this section is the full taxonomy. If you only needed the fork, you’re done.

Dummy: Passed around but never used. Fills parameter lists so your code compiles.
const dummyLogger: Logger = {
info: () => {},
error: () => {},
warn: () => {},
};Stub: Returns canned answers. Makes the system under test behave a certain way.
const stubClock: Clock = {
now: () => new Date("2026-01-15T10:00:00Z"),
};Spy: A stub that also records calls so you can verify them later.
const spyNotifier: Notifier = {
sent: [] as string[],
send(message: string) {
this.sent.push(message);
},
};Mock: Pre-programmed with expectations. Verifies that specific interactions occurred.
const mockGateway = {
charge: jest.fn().mockResolvedValue({ ok: true, value: { id: "pay-1" } }),
};
// Later: expect(mockGateway.charge).toHaveBeenCalledWith(5000, "usd");Fake: A working implementation that’s just not production-grade. In-memory database, fake email sender, test clock.
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async save(user: User): Promise<Result<void, never>> {
this.users.set(user.getId().getValue(), user);
return { ok: true, value: undefined };
}
async findByEmail(email: string): Promise<Result<User, NotFoundError>> {
for (const user of this.users.values()) {
if (user.getEmail().getValue() === email) {
return { ok: true, value: user };
}
}
return { ok: false, error: new NotFoundError("User", email) };
}
}Here’s the key insight: the further right you go on this spectrum, the more your tests describe behavior instead of implementation.
Fakes let you test “what happened” instead of “what was called.” That’s the shift that makes tests survive refactoring.
My rule of thumb these days:
- Fakes for collaborators that have a clear interface (repositories, queues, file systems)
- Stubs when I need a specific response (time, randomness, external API data)
- Mocks only when the interaction itself is the behavior (event publishing, notification sending)
- Never mock collaborators I own; I fake them instead
The Problem With Mocks
Let me show you exactly why mocks make tests brittle.
Here’s a UserService that registers users, sends a welcome email, and logs the event:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
class UserAlreadyExistsError {
readonly message = "User already exists";
}
class RegistrationError {
constructor(public readonly reason: string) {}
}
class UserService {
constructor(
private readonly repo: UserRepository,
private readonly emailService: EmailService,
private readonly auditLog: AuditLog
) {}
async register(
emailStr: string,
age: number
): Promise<Result<User, UserAlreadyExistsError | RegistrationError>> {
const existing = await this.repo.existsByEmail(emailStr);
if (existing.ok && existing.value) {
return err(new UserAlreadyExistsError());
}
const userResult = User.create(emailStr, age);
if (!userResult.ok) {
return err(new RegistrationError(userResult.error.message));
}
const saveResult = await this.repo.save(userResult.value);
if (!saveResult.ok) {
return err(new RegistrationError(saveResult.error.message));
}
await this.emailService.sendWelcome(emailStr);
await this.auditLog.log("user_registered", { email: emailStr });
return ok(userResult.value);
}
}Now here’s the mock-based test:
// ❌ Mock-heavy test — coupled to every implementation detail
it("should register a user successfully", async () => {
const mockRepo = {
existsByEmail: jest.fn().mockResolvedValue({ ok: true, value: false }),
save: jest.fn().mockResolvedValue({ ok: true, value: undefined }),
};
const mockEmail = {
sendWelcome: jest.fn().mockResolvedValue(undefined),
};
const mockAudit = {
log: jest.fn().mockResolvedValue(undefined),
};
const service = new UserService(mockRepo, mockEmail, mockAudit);
const result = await service.register("[email protected]", 25);
expect(result.ok).toBe(true);
expect(mockRepo.existsByEmail).toHaveBeenCalledWith("[email protected]");
expect(mockRepo.save).toHaveBeenCalled();
expect(mockEmail.sendWelcome).toHaveBeenCalledWith("[email protected]");
expect(mockAudit.log).toHaveBeenCalledWith(
"user_registered",
expect.objectContaining({ email: "[email protected]" })
);
});Three problems with this test:
1. False positives. I told mockRepo.save to return success. So of course the test passes. I rigged it. If the real repository has a bug in its save logic, this test will never catch it.
2. Coupling to structure. I’m asserting that save was called with specific arguments. If I change User to use a Value Object for email (which I should), the expect.objectContaining({ email: "[email protected]" }) assertion might break, even though the behavior is correct.
3. Coupling to collaborators. The test knows about four collaborators. If I add a fifth (say, a WelcomeSeries that enrolls the user in an onboarding drip), I need to update every single test that touches registration. The test suite becomes a maintenance burden instead of a safety net.
The Fake Alternative
Here’s the same test using fakes:
// ✅ Fake-based test — tests behavior, survives refactoring
it("should register a user and persist them", async () => {
const repo = new InMemoryUserRepository();
const emailService = new FakeEmailService();
const auditLog = new InMemoryAuditLog();
const service = new UserService(repo, emailService, auditLog);
const result = await service.register("[email protected]", 25);
expect(result.ok).toBe(true);
if (!result.ok) return;
const found = await repo.findByEmail("[email protected]");
expect(found.ok).toBe(true);
if (found.ok) {
expect(found.value.getEmail().getValue()).toBe("[email protected]");
}
expect(emailService.sentEmails).toHaveLength(1);
expect(emailService.sentEmails[0].to).toBe("[email protected]");
expect(emailService.sentEmails[0].subject).toMatch(/welcome/i);
expect(auditLog.entries).toHaveLength(1);
expect(auditLog.entries[0].event).toBe("user_registered");
});Notice what changed:
- I verify the outcome, not the method calls. The test checks that the user exists in the repository with the right email. That’s behavior.
- I use real code paths. The
InMemoryUserRepositoryruns actual logic. It stores, it retrieves, it handles not-found cases. If I breaksave, this test catches it. - Refactoring is safe. If I add a
WelcomeSeriescollaborator, this test doesn’t change. It doesn’t care how registration works; it cares that the user ends up registered.
The fakes are straightforward to write. Here’s the InMemoryUserRepository, which you’ve seen if you read the Aggregates & Repositories article:
interface UserRepository {
save(user: User): Promise<Result<void, PersistenceError>>;
findByEmail(email: string): Promise<Result<User, NotFoundError>>;
existsByEmail(email: string): Promise<Result<boolean, PersistenceError>>;
delete(id: string): Promise<Result<void, NotFoundError>>;
}
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async save(user: User): Promise<Result<void, never>> {
this.users.set(user.getId().getValue(), user);
return ok(undefined);
}
async findByEmail(email: string): Promise<Result<User, NotFoundError>> {
for (const user of this.users.values()) {
if (user.getEmail().getValue() === email) {
return ok(user);
}
}
return err(new NotFoundError("User", email));
}
async existsByEmail(email: string): Promise<Result<boolean, never>> {
for (const user of this.users.values()) {
if (user.getEmail().getValue() === email) {
return ok(true);
}
}
return ok(false);
}
async delete(id: string): Promise<Result<void, NotFoundError>> {
if (!this.users.has(id)) {
return err(new NotFoundError("User", id));
}
this.users.delete(id);
return ok(undefined);
}
}And the fake email service:
interface SentEmail {
to: string;
subject: string;
body: string;
}
class FakeEmailService implements EmailService {
public sentEmails: SentEmail[] = [];
async sendWelcome(email: string): Promise<void> {
this.sentEmails.push({
to: email,
subject: "Welcome aboard!",
body: `Hello ${email}, thanks for signing up.`,
});
}
async sendPasswordReset(email: string, token: string): Promise<void> {
this.sentEmails.push({
to: email,
subject: "Reset your password",
body: `Use this token: ${token}`,
});
}
}These fakes are small, obvious, and do real work. They’re not pretending to be the production implementations. They’re simplified versions that exercise the same code paths your domain logic depends on.
When Mocks Are Actually Useful
I’m not saying “never use mocks.” That would be dogmatic, and dogma doesn’t help anyone.
Mocks are the right tool when the interaction itself is the behavior you’re testing. Specifically: side effects that you can’t observe through the return value.
Example one: event publishing.
it("should publish OrderSubmitted event when order is submitted", async () => {
const eventBus = mock<EventBus>();
const order = Order.create(UserId.generate()).value!;
order.submit();
expect(eventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ type: "OrderSubmitted" })
);
});The event is fire-and-forget. There’s no return value to check. The only way to verify this behavior is to check that publish was called. That’s a legitimate use of a mock.
Example two: notification thresholds.
it("should lock account and notify admin after 3 failed payments", async () => {
const notifier = mock<AdminNotifier>();
const account = Account.create(UserId.generate());
account.recordFailedPayment();
account.recordFailedPayment();
account.recordFailedPayment();
expect(account.isLocked()).toBe(true);
expect(notifier.notifyAdmin).toHaveBeenCalledWith(
expect.stringContaining("account locked")
);
});The lock is observable through isLocked(). But the admin notification is a side effect; I need to verify it happened.
My rule: if you’re asking “did X happen?”, a mock is fine. If you’re asking “did X produce the right result?”, use a fake.
How to Write a Fake (in 3 Steps)
You decided you want a fake. Here’s the recipe:
- Find the boundary. A fake replaces a collaborator that crosses a real boundary — a database, a network, a clock, a filesystem, a queue. If your collaborator is pure logic, you don’t need a fake; call it directly.
- Implement the interface, in memory. Use a
Map, aDate.now()-controlled clock, a fixed array. The fake runs real code paths, just with simpler state. - Assert the outcome, not the call.
expect(repo.findByEmail(...))returns the user — that’s the assertion. You don’t ask “wassavecalled with this argument?” You ask “is the user there now?”

That’s the whole pattern. The next section shows it fleshed out.
Building Fakes for Your Domain
There’s a pattern to building fakes. Once you see it, you can create one for any interface in minutes.
The core idea: use an in-memory Map as your store, and implement the interface methods with simple lookups.
class InMemoryRepository<
T extends { getId: () => string },
> implements Repository<T> {
private items = new Map<string, T>();
async getById(id: string): Promise<Result<T, NotFoundError>> {
const item = this.items.get(id);
if (!item) return err(new NotFoundError("Item", id));
return ok(item);
}
async save(item: T): Promise<Result<void, never>> {
this.items.set(item.getId(), item);
return ok(undefined);
}
async delete(id: string): Promise<Result<void, NotFoundError>> {
if (!this.items.has(id)) return err(new NotFoundError("Item", id));
this.items.delete(id);
return ok(undefined);
}
async findAll(): Promise<Result<T[], never>> {
return ok(Array.from(this.items.values()));
}
clear(): void {
this.items.clear();
}
}This generic template works for most aggregates. For specific queries, extend it:
class InMemoryOrderRepository extends InMemoryRepository<Order> {
async findByCustomerId(customerId: UserId): Promise<Result<Order[], never>> {
const all = await this.findAll();
if (!all.ok) return ok([]);
return ok(all.value.filter((o) => o.getCustomerId().equals(customerId)));
}
async findByStatus(status: OrderStatus): Promise<Result<Order[], never>> {
const all = await this.findAll();
if (!all.ok) return ok([]);
return ok(all.value.filter((o) => o.getStatus() === status));
}
}For non-repository collaborators, the pattern is similar: keep an array or a flag, and record what happened:
class InMemoryAuditLog implements AuditLog {
public entries: Array<{ event: string; payload: unknown }> = [];
async log(event: string, payload: unknown): Promise<void> {
this.entries.push({ event, payload });
}
}
class FakePaymentGateway implements PaymentGateway {
public charges: Array<{ amount: number; currency: string }> = [];
private shouldFail = false;
async charge(
amount: number,
currency: string
): Promise<Result<PaymentReceipt, PaymentError>> {
if (this.shouldFail) {
return err(new PaymentError("Gateway unavailable"));
}
const receipt = new PaymentReceipt(
`pay-${this.charges.length + 1}`,
amount,
currency
);
this.charges.push({ amount, currency });
return ok(receipt);
}
simulateFailure(): void {
this.shouldFail = true;
}
}The simulateFailure method is worth noting. Fakes can be configurable; you can inject specific scenarios (failure, timeout, rate limiting) without mock setup. This makes tests more readable because the scenario is visible in the fake’s state, not buried in mockRejectedValue chains.
What Changed for Me
After migrating my test suite from mocks to fakes:
- Tests got shorter. Average test file went from ~120 lines to ~60 lines. Less setup, more assertions that matter.
- Refactoring became fast again. I changed
Userto use Value Objects for email and age. Zero test changes. The fakes kept working. - Bug detection improved. My mocks were hiding a bug in
UserRepository.save; they always returned success. The fake caught it because it runs real logic. - Test suite got faster. In-memory fakes are instant. No database, no network, no I/O. My suite went from 45 seconds to 3 seconds.
The principle: test at the edge, not the implementation. Every fake in this post tests at a boundary — repository, queue, payment gateway. The boundary is the contract. The implementation behind it can change. Your tests survive the change because they test the contract, not the path.
FAQ
Mock vs. fake — what’s the actual difference?
A mock is pre-programmed with expected interactions. You set up what it should return and then verify that specific methods were called with specific arguments. A fake is a working implementation that uses a simpler mechanism (like in-memory storage instead of a database). Fakes run real code; mocks return what you tell them to return.
Are mocks ever the right choice in TDD?
Use mocks when the behavior you’re testing is an interaction that produces a side effect, like publishing an event, sending a notification, or triggering a webhook. If the only way to verify that something happened is to check “was this function called?”, a mock is appropriate. For everything else, fakes give you more confidence.
What makes a mock-heavy test brittle?
Mock-heavy tests assert how code works (which methods are called, in what order, with what arguments) instead of what code does (what outcome is produced). When you refactor internal structure (rename a method, add a collaborator, change an argument shape), mocks break even though the behavior hasn’t changed. This makes mocks a maintenance burden and erodes trust in the test suite.
Filed under
Next in TDD & Software Design
The Result Pattern: Why I Stopped Throwing Exceptions
Part 7 continues the series.