Stubs, Mocks, and Other Friendly Lies We Tell Our Tests

Sometimes the API isn’t ready. Sometimes it’s slow. Sometimes it’s flaky. And sometimes you just don’t want your test suite talking to the outside world at all.

Stubs, Mocks, and Other Friendly Lies We Tell Our Tests Why We Lie to Our Tests Tests are supposed to give us confidence. But sometimes, they do the opposite: they’re slow, flaky, or impossible to run without a full production environment. Waiting for real APIs, dealing with network failures, or trying to reproduce a rare edge case can make testing feel like a cruel joke. Stubs, mocks, and fakes, the “friendly lies” we tell our tests, let your code believe the world is stable and predictable, even when it isn’t. With them, you can simulate slow APIs, handle edge cases instantly, and isolate the code you actually want to test. Think of them as test doubles: they don’t replace reality, but they make your tests faster, safer, and way less frustrating. What Are Stubs, Mocks, Fakes, and Spies? If the first section was about why we lie to our tests, this one is about how we do it. In testing, we have a toolbox of “friendly lies,” each with a slightly different purpose: Stub: a stub is like a polite assistant that hands out pre-defined answers. Your code asks a question, and the stub responds with exactly what you tell it to, no surprises. Use stubs when you want to control data or responses, especially for edge cases that are hard to reproduce. Use stubs to simplify input/output. Mock: a mock is a strict observer. It not only pretends to respond, but also checks that your code interacts with it correctly. Did you call the API the right number of times? Did you pass the correct parameters? Mocks are ideal when behavior and interactions matter, not just results. Use mocks to enforce correct interactions. Fake: a fake is a lightweight, working substitute. Think of an in-memory database instead of hitting a real database. Fakes are useful when you need realistic behavior without external dependencies, so your tests can run fast and reliably. Use fakes for realistic, lightweight alternatives. Spy: A spy sits quietly and watches. It records what happens: calls, parameters, and result, without changing the behavior of the code under test. Spies are perfect for observing side effects without interfering. Use spies to observe behavior without altering it. Patterns for Mocking APIs Hard-Coded Responses vs Dynamic Behavior Sometimes you just need the API to return a fixed response for testing the happy path: const getUserStub = () => ({ id: 1, name: 'Alice' }); function greetUser(userApi: () => { id: number; name: string }) { const user = userApi(); return `Hello, ${user.name}!`; } console.log(greetUser(getUserStub)); // "Hello, Alice!" But real-world scenarios often require dynamic responses. For instance, returning different users based on input: const getUserDynamic = (id: number) => { const users = { 1: 'Alice', 2: 'Bob' }; return { id, name: users[id] || 'Unknown' }; }; console.log(getUserDynamic(2)); // { id: 2, name: 'Bob' } Dynamic behavior allows you to simulate multiple cases and edge conditions without calling the real API. Mocking Asynchronous APIs & Promises Most APIs are async, so your mocks need to mimic that behavior: const fetchUser = async (id: number): Promise<{ id: number; name: string }> => { return new Promise(resolve => setTimeout(() => resolve({ id, name: 'Alice' }), 100)); }; // async stub for testing const fetchUserStub = async (id: number) => ({ id, name: 'Test User' }); async function greetAsync(userApi: (id: number) => Promise<{ id: number; name: string }>) { const user = await userApi(1); return `Hello, ${user.name}!`; } greetAsync(fetchUserStub).then(console.log); // "Hello, Test User!" This ensures your tests behave like the real asynchronous world, but without the network delays. Dependency Injection for Easier Testing Instead of hardcoding API calls, pass the API client or function into your code. This makes it easy to swap in a stub or mock during tests: function greetWithApi(userId: number, userApi: (id: number) => { id: number; name: string }) { const user = userApi(userId); return `Hello, ${user.name}!`; } // During tests, inject a stub console.log(greetWithApi(1, () => ({ id: 1, name: 'Stubbed User' }))); Dependency injection decouples your code from the real API and makes testing predictable. Using Mocking Libraries Libraries like Jest or Sinon let you create mocks, spies, and stubs more easily and verify interactions: import { jest } from '@jest/globals'; const fetchUserMock = jest.fn().mockReturnValue({ id: 1, name: 'Alice' }); function greet(userApi: () => { id: number; name: string }) { const user = userApi(); return `Hello, ${user.name}!`; } greet(fetchUserMock); console.log(fetchUserMock.mock.calls.length); // 1 With libraries, you can check how many times a function was called, with what arguments, and control its return values, which is invaluable for testing interactions with external systems. Common Pitfalls and Anti-Patterns Even though mocking and stubbing are powerful, they can backfire if used carelessly. Let’s look at the most common mistakes. Over-Mocking Everything → Fragile Tests It’s tempting to mock every dependency, but doing so can make tests brittle. If you change implementation details, tests break even though the behavior is correct. Only mock what’s external, slow, or unpredictable. Don’t mock internal logic unnecessarily. Testing Mocks Instead of Behavior Tests should validate what the code does, not how it interacts with mocks. const apiMock = jest.fn().mockReturnValue(42); function computeAnswer(api: () => number) { return api() + 1; } // bad test: focuses on mock calls computeAnswer(apiMock); expect(apiMock).toHaveBeenCalledTimes(1); // better test: focuses on behavior expect(computeAnswer(() => 42)).toBe(43); Lesson: Your test should assert outcomes or behavior, not just that the mocks were called correctly. Coupling Tests Too Tightly to Implementation If tests mirror the internal structure too closely, refactoring becomes painful. // implementation detail: splitting logic into small private functions function internalLogic(x: number) { return x * 2; } function processInput(input: number, api: (n: number) => number) { return internalLogic(api(input)); } // test tied to internalLogic const apiStub = (n: number) => n + 1; expect(processInput(2, apiStub)).toBe(6); Change internalLogic slightly and the test fails, even though the external behavior is unchanged. Lesson: Write tests against public behavior and outputs, not internal steps. Conclusion Mocks, stubs, fakes, and spies aren’t cheating, they’re tools that let your tests work smarter, not harder. Used wisely, they make tests fast, reliable, and maintainable, while giving you confidence that your code behaves correctly. The goal isn’t to trick your tests, it’s to create a stable playground where your code can be exercised thoroughly without relying on flaky external systems. Think of them as friendly lies: small fictions that save you from slow, brittle tests and let you focus on what really matters, the behavior of your code. When your test suite runs quickly and consistently, you can experiment, refactor, and ship with confidence.