Testing and debugging · Lesson 69 of 77 · concept

Test doubles without a framework

About 50 minutes.

By the end of this lesson you can

  • Explain what a test double is and which problem it exists to solve.
  • Pass a dependency in as an argument so that a test can control it.
  • Write a spy that records every call it received, and assert on those calls.
  • Say when a double is the wrong tool and what a test full of doubles really proves.

Some code cannot be tested, and it is not your test's fault

Explanation

A function that reads the clock gives a different answer at nine in the morning and at nine at night. A function that picks a random number gives a different answer every time. A function that sends an email produces nothing you can inspect and something you very much do not want happening a hundred times while a test suite runs.

In all three cases the trouble is the same, and it is worth naming precisely: the function reaches out and gets something the test cannot control, or pushes something out where the test cannot see it. Not one of those is a bug in your testing - it is a shape problem in the code, and the fix is to change the shape.

A test double is the stand-in you hand the code instead of the real thing: a clock that always says the same time, a sender that quietly writes into an array. The word comes from the film industry, where a stunt double stands in for the actor in the one scene that would be dangerous or impossible to shoot for real. Same job here - and, as in film, the double is only convincing for that scene.

Pass the unpredictable thing in as an argument

Explanation

There is one move that makes almost anything testable, and it is embarrassingly simple: whatever the function reaches out for, make it an argument instead. A function that calls the clock becomes a function that takes a now argument. A function that sends becomes a function that takes a send argument. This is called dependency injection, and the impressive name describes a change you could explain to a child.

In real use the caller passes the real clock and the real sender, so nothing about production behaviour changes. In a test you pass a function that always returns the same timestamp and one that records what it was given. Now the test controls the input completely and can see the output completely, which is the entire requirement for a test that means something.

There is a bonus you did not ask for. Code written this way is easier to reuse, because the function no longer insists on one particular clock or one particular sender - and it is easier to read, because everything it depends on is listed in its parameters instead of hidden three lines down. The habit of making dependencies visible pays for itself long before the tests do.

Stub, spy, fake - three jobs, one idea

Explanation

A stub answers. It exists to give the code under test a fixed reply so that the interesting behaviour can be reached: a clock that always says noon, a lookup that always returns the same customer.

A spy records. It exists so the test can afterwards ask what happened: how many times were you called, and with what? A spy is usually a function that pushes its arguments into an array you can read later, which is exactly what you will write in the exercise.

A fake works, simply. It is a real, small implementation standing in for a large one - an in-memory array where the real thing is a database. It behaves correctly for the cases you care about and cuts every corner you do not.

The vocabulary is not standardised: some frameworks call all three mocks, some draw the lines elsewhere, and the arguments about it are not worth your time. What matters is the question each one answers. Am I feeding the code a reply it needs (stub), am I checking what the code did to the outside world (spy), or am I substituting a working part (fake)?

A test made entirely of doubles proves that the doubles work

A common mistake

The first mistake is asserting that a spy was called and stopping there. 'notify was called' is nearly free information - it is 'notify was called once with grace and the shipped message' that would have caught the bug where every customer got the wrong text. If a spy is worth having, assert on its recorded arguments.

The second is doubling too much. Replace every dependency of a function and the test no longer touches anything real; it checks that your stand-ins were used in the order you wrote them, which is a test of the test. A useful rule of thumb: double the things that are slow, unpredictable or have real-world side effects, and let plain data and plain functions be themselves.

The third is the sneakiest. Doubles freeze an assumption about how the real thing behaves. Your fake sender never fails; the real one does. Your fixed clock never crosses midnight; the real one does, at midnight. When a double and reality drift apart, every test still passes and the product is broken - which is why the interface you are doubling should be small enough that you can hold its real behaviour in your head.

What to hold on to

Recap

If a function reaches out for the clock, randomness or the outside world, the test cannot control it - so pass that thing in as an argument instead. A stub answers, a spy records, a fake works simply. Assert on what the spy was called WITH, not merely that it was called. And keep the doubled surface small, because every double is an assumption about reality that nothing will re-check for you.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

The version you cannot write a stable test for

One line makes this function untestable. Find it before reading the annotations, and ask yourself what you would have to write in a test to check the morning branch.

1function greetingFor(name) {2  const hour = new Date().getHours();3  if (hour < 12) {4    return "Good morning, " + name;5  }6  return "Good afternoon, " + name;7}8 9console.log(greetingFor("ada"));
  • line 2

    The function reaches out and takes something the caller never gave it. Nothing a test can do from the outside changes what this line returns.

  • line 3

    Two branches, and which one you get depends on when the test suite happens to run. A test asserting 'Good morning' passes all morning and fails after lunch - the definition of a flaky test.

  • line 9

    Even this line has no fixed answer, which is why this example is not runnable here: the output would be true only at the moment it was written.

This one does not run hereIts result depends on the time of day it happens to run, so there is no fixed output to print. That unpredictability is the whole point of the example - any expected output written here would be a fiction, which is exactly the complaint the lesson is making about the code.

The same behaviour, with the clock and the sender handed in

Both unpredictable things are now arguments. Watch what that buys the test: a timestamp it chose itself, and an array it can read afterwards to see exactly what was sent.

1function confirmOrder(order, deps) {2  const line = "Order " + order.id + " confirmed at " + deps.now();3  deps.send(line);4  return line;5}6 7const sent = [];8 9function fakeSend(message) {10  sent.push(message);11}12 13function fixedNow() {14  return "2026-01-01T09:00:00Z";15}16 17const result = confirmOrder({ id: 7 }, { now: fixedNow, send: fakeSend });18 19console.log(result);20console.log(sent.length);21console.log(sent[0] === result);
  • line 1

    Everything this function depends on is now visible in its parameters. In production the caller passes the real clock and the real sender, and this line does not change.

  • line 9

    A fake sender: a working, tiny implementation that puts the message somewhere the test can look instead of somewhere it cannot.

  • line 13

    A stub clock. It answers, always the same way, so the assertion below can be an exact comparison rather than a guess.

  • line 20

    1 - the message was sent exactly once. Asserting on the count catches the duplicate-send bug that 'it was called' would sail straight past.

  • line 21

    true - what was sent is the same text that was returned. This is the assertion with real content: not that something happened, but what.

What it prints

Order 7 confirmed at 2026-01-01T09:00:00Z1true

A spy, built from a function and an array

The whole of vi.fn() and jest.fn(), minus the reporting. A function that remembers what it was handed, because a function in JavaScript is an object and can carry a property of its own.

1function createSpy(returnValue) {2  const spy = function () {3    const args = [];4    for (let index = 0; index < arguments.length; index = index + 1) {5      args.push(arguments[index]);6    }7    spy.calls.push(args);8    return returnValue;9  };10  spy.calls = [];11  return spy;12}13 14function notifyAll(names, notify) {15  for (const name of names) {16    notify(name, "your order shipped");17  }18}19 20const notify = createSpy();21notifyAll(["ada", "grace"], notify);22 23console.log(notify.calls.length);24console.log(notify.calls);25console.log(notify.calls[1][0]);
  • line 7

    One call becomes one array of its arguments, pushed onto the record. Storing the arguments rather than just a count is what lets a test ask 'with what'.

  • line 10

    A property hung on the function itself. Functions are objects, so this is ordinary property assignment - and it is why the spy and its record travel together as one value.

  • line 16

    notifyAll knows nothing about spies. It takes a function and calls it, which is the only reason a double can be slipped in at all.

  • line 24

    Two calls, each recorded with both arguments: [["ada","your order shipped"],["grace","your order shipped"]]. A duplicate send or a wrong message would be visible right here.

What it prints

2[["ada","your order shipped"],["grace","your order shipped"]]grace

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

Two spies are created from the same factory and only the first is called - twice, with one argument and then with two. Predict all three lines.

function createSpy() {  const spy = function () {    spy.calls.push(arguments.length);  };  spy.calls = [];  return spy;}const first = createSpy();const second = createSpy();first("a");first("b", "c");console.log(first.calls);console.log(second.calls);console.log(typeof first);

chargeCard reads the current time inside itself to stamp the receipt. What does passing the clock in as an argument buy you?

A test replaces the email sender with a spy and asserts only that the spy was called. Which bug slips through?

Write it yourself

Exercise · core · 8 tests

Build the spy the frameworks give you

Write createSpy(returnValue). It returns a function that can be handed to any code expecting a callback. Every time that function is called it must record the arguments of that call - as an array, in call order - on its own calls property, and then return returnValue (which is undefined when createSpy was given nothing). Two spies made by two separate calls to createSpy must not share a record. The starter returns the right value and keeps an empty calls array that nothing ever writes to, so it lies quietly rather than loudly.

What your code must do

createSpy returns a function with a calls property holding one entry per call, each entry an array of that call's arguments in order - so a call with no arguments records an empty array. The returned function always returns the returnValue given to createSpy, or undefined if there was none. Separate spies have separate calls arrays.

Define createSpy at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • What problem does a test double solve?

    The code reaches for something the test cannot control (clock, randomness, the network) or pushes something where the test cannot see it. A double replaces that thing.

  • What is dependency injection, in one sentence?

    Passing the thing a function depends on in as an argument, instead of the function reaching out for it, so a caller - or a test - can choose it.

  • What is the difference between a stub and a spy?

    A stub answers: it supplies a fixed reply the code needs. A spy records: it remembers the calls it received so the test can assert on them afterwards.

  • Why is 'the spy was called' a weak assertion?

    It only separates 'sent nothing' from 'sent something'. The bugs live in what was sent - wrong recipient, wrong text, twice - so assert on the recorded arguments.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — Rest parameters

Check there: A rest parameter collects the remaining arguments of a call into a real array, which supports array methods directly - unlike the arguments object.

MDN — Function

Check there: Every JavaScript function is a Function object, which is why a property such as calls can be assigned to one.

MDN — Functions guide

Check there: Functions are values: they can be passed to other functions as arguments and returned from them.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 1 exercise in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences