Expert · Functional patterns and testing · topic 51 of 61 · 25 min
Test doubles and testable design
Explanation
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
| Double | What it does | Example |
|---|---|---|
| Stub | Returns a fixed answer | getRate() always returns 18 |
| Spy | Records how it was called | sendSms was called once with "9876543210" |
| Mock | A spy with expectations set in advance | expect(sendSms).toHaveBeenCalledWith(...) |
| Fake | A simple working version | An 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".
// 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
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
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.
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.
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
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.
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 0Practice (0/3 done)
Write makeSpy(returnValue) that returns a function which records each call's arguments in spy.calls (an array of argument arrays) and returns returnValue.
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.
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
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Memoization, debounce and throttleFunctional patterns and testing · same module
- Composition, currying and pipelinesFunctional patterns and testing · same module
- Test doubles: fakes, stubs and mocksC# and .NET · Testing, tooling and Unity
- Unit testing: checking one piece at a timeBackend Development · Testing and API documentation
- Testing a full-stack appFull-Stack Development · Performance, security and testing
- Spring and dependency injectionJava Programming (Core to Spring Boot) · Spring Boot basics
Questions people ask about this
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.
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.
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.
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
- This is the last topic of “Functional patterns and testing”. The module exam comes next.
- Module 10 exam: Functional patterns and testing · 12 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Performance and Node.js. 61 topics left in the course before the final exam.