Skip to content

Expert · Functional patterns and testing · topic 51 of 61 · 25 min

Test doubles and testable design

Explanation

Real code talks to slow or unpredictable things: servers, databases, the clock, random numbers. In tests you replace them with test doubles: stubs that return fixed answers, spies that record calls, and mocks that check expectations. The easiest way to make this possible is dependency injection: pass those things in as parameters.

The problem

How do you test a function that sends an SMS, reads today's date, or calls a payment gateway? You cannot send real SMS on every test run, the date changes every day, and the gateway charges money. Tests must be fast, repeatable and free. So you replace those parts with stand-ins during the test. A stand-in is called a test double (like a stunt double in a film).

Kinds of test doubles

DoubleWhat it doesExample
StubReturns a fixed answergetRate() always returns 18
SpyRecords how it was calledsendSms was called once with "9876543210"
MockA spy with expectations set in advanceexpect(sendSms).toHaveBeenCalledWith(...)
FakeA simple working versionAn in-memory array instead of a real database

Dependency injection: pass things in

Dependency injection means a function receives the things it depends on (fetch, the clock, a database object) as parameters instead of reaching for globals. Then a test can pass in a double. It sounds fancy, but it is just "use parameters".

javascript
// Hard to test: uses the real clock and the real SMS service
function sendReminderHard(user) {
  if (new Date().getHours() >= 21) return 'too late';
  return realSmsService.send(user.phone, 'Your appointment is tomorrow');
}

// Easy to test: dependencies come in as parameters (with real defaults)
function sendReminder(user, { now = () => new Date(), sms = realSmsService } = {}) {
  if (now().getHours() >= 21) return 'too late';
  return sms.send(user.phone, 'Your appointment is tomorrow');
}

A hand-made spy

javascript
function makeSpy(returnValue) {
  const spy = (...args) => { spy.calls.push(args); return returnValue; };
  spy.calls = [];
  return spy;
}
const send = makeSpy('sent');
const result = sendReminder({ phone: '9876543210' }, {
  now: () => new Date(2026, 0, 15, 10, 0),   // stub: always 10 a.m.
  sms: { send },
});
console.log(result, send.calls); // 'sent' [['9876543210', 'Your appointment is tomorrow']]

With Vitest or Jest

javascript
import { vi, it, expect } from 'vitest';
it('sends one SMS before 9 p.m.', () => {
  const send = vi.fn().mockReturnValue('sent');      // a mock function
  sendReminder({ phone: '98…' }, { now: () => new Date(2026, 0, 15, 10), sms: { send } });
  expect(send).toHaveBeenCalledTimes(1);
});
// vi.useFakeTimers() / vi.setSystemTime(...) control setTimeout and Date in tests.

Flaky tests

A flaky test sometimes passes and sometimes fails without any code change. Causes: real time and timers, randomness, real network calls, tests that depend on each other's order, or shared state left over from another test. Fix them by controlling time and randomness with doubles, and by giving every test its own fresh data.

Careful: Do not mock everything. If a test replaces every collaborator, it only checks that your code calls functions in a certain order, and it breaks whenever you refactor, even if behaviour is still correct. Mock the slow, external or random parts (network, time, payments); use the real pure logic.

Snapshot tests (in brief)

Snapshot tests save the output of a function (for example a rendered component or a big object) to a file the first time, and fail if it changes later. They are quick to write but easy to update without thinking. Review snapshot changes as carefully as code changes.

Think of it like this: Testing with doubles is like a fire drill. You do not set the building on fire to test the alarm; you press a test button (a stub) and check that people leave by the right exits (a spy recording what happened).
Tip: If something is hard to test, the design is usually trying to tell you something: the function does too much or hides its dependencies. Split it and pass dependencies in.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Test doubles and testable design · 1/6

Test doubles

In this lesson you will test code that talks to servers, clocks and other slow or unpredictable things.

Prefer a video by a teacher? Find free videos on “javascript mocking stubs spies vitest vi.fn dependency injection” on YouTube ↗

In real life: Testing a payment flow without charging anyone

An online coaching institute's enrolment code charges a fee through a payment gateway and then emails a receipt. In tests, the gateway is replaced by a stub that returns success or "card declined", and the email sender by a spy. The tests check that a declined card sends NO receipt, without touching a real card.

javascript
async function enrol(student, { gateway, mailer }) {
  const res = await gateway.charge(student.fee);
  if (!res.ok) return 'payment failed';
  mailer.send(student.email, 'Receipt');
  return 'enrolled';
}
const sent = [];
const mailer = { send: (...a) => sent.push(a) };
enrol({ fee: 5000, email: 'a@b.in' }, { gateway: { charge: async () => ({ ok: false }) }, mailer })
  .then((r) => console.log(r, sent.length)); // payment failed 0

Practice (0/3 done)

Task 1 · Code · Javascript

Write makeSpy(returnValue) that returns a function which records each call's arguments in spy.calls (an array of argument arrays) and returns returnValue.

Task 2 · Code · Javascript

Refactor greeting() so the clock is injected: greeting(now = () => new Date()) returns "Good morning" before 12:00, "Good afternoon" from 12:00 to 16:59, and "Good evening" from 17:00. The test passes fake clocks.

Task 3 · Code · Javascript

Write checkout(cart, { charge, notify }) (async). It calls await charge(total) where total is the sum of price × qty. If the result has ok: true, call notify("Order placed: Rs <total>") and return "placed"; otherwise do NOT notify and return "failed". The tests inject stubs and spies.

Quick quiz

Question 1

1.What is a stub?

Question 2

2.What does dependency injection mean in practice?

Question 3

3.Which is a common cause of flaky tests?

Question 4

4.Why not mock every collaborator?

Read more (free)

Before this, learn

This topic builds on these. If something here feels new, open them first.

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What is a stub?

Answer: A double that returns a fixed answer

Stubs replace a dependency with a canned response.

What does dependency injection mean in practice?

Answer: Passing dependencies in as parameters instead of using globals

It is mostly "use parameters for the things you depend on".

Which is a common cause of flaky tests?

Answer: Using the real clock and real network

Time and network make results change between runs.

Why not mock every collaborator?

Answer: Tests then check call order, not behaviour, and break on every refactor

Over-mocking couples tests to implementation details.

Which design makes test doubles easy to use?Related

Answer: Depending on interfaces passed in through the constructor

Constructor injection of interfaces.

From Test doubles: fakes, stubs and mocks

Your code sends real SMS messages. How should a unit test handle this?Related

Answer: Pass in a fake SMS sender that only records messages

A fake (a test double) stands in for the slow or risky part, and the test checks what the code asked it to do.

From Unit testing: checking one piece at a time

Which test kind should you have the MOST of?Related

Answer: Unit

Unit tests are fast and pinpoint failures; the pyramid has them at the base.

From Testing your code

A test passes on your laptop but fails about one time in five in CI with no code change. What is it?Related

Answer: A flaky test

A flaky test gives different results without code changes, often because of timing, shared data or the current date.

From Testing a full-stack app

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. This is the last topic of “Functional patterns and testing”. The module exam comes next.
  2. Module 10 exam: Functional patterns and testing · 12 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Performance and Node.js. 61 topics left in the course before the final exam.

← Previous: Testing your code