In the previous lesson we tested the pure Escena Viva domain in fifty-two milliseconds. It was easy because Session, Event and policy.js depend on nothing: you give them data, they give you results. The real application, on the other hand, is full of dependencies that destroy determinism.

  • src/services/currency-exchange.js calls an external API with fetch: if the provider is down, the test fails without our code having any bug at all, and if it works it takes 400 ms and burns quota.
  • generateTicketCode builds codes of the form EV-<year>-<6 digits> using the current year: that test will pass until December 31 and fail on January 1.
  • Access tokens expire after 15 minutes. Testing expiry as is would require a fifteen-minute test.
  • The repositories open connections to Mongo. A unit test of a controller should not need a database.

The solution is test doubles: objects that stand in for the real dependencies during the test, just as a stunt double stands in for the actor in the dangerous scene. In Node the standard tool is Sinon.

Contents

  1. A taxonomy of test doubles
  2. sinon.spy: observing without changing
  3. sinon.stub: replacing the behavior
  4. Always restore: sandboxes and contamination
  5. Fake clocks
  6. Faking fetch to test currency-exchange.js
  7. Faking the repository to test a controller
  8. Dependency injection versus module patching
  9. When NOT to use doubles

A taxonomy of test doubles

The terminology comes from Gerard Meszaros and shows up throughout the literature and in the API names themselves.

Double What it is When to use it In Sinon
Dummy A value passed only to fill a parameter; never used A required argument that is irrelevant to the test {}, null, () => {}
Stub Replaces the dependency by returning preprogrammed answers Controlling what goes into the unit under test sinon.stub()
Spy Wraps the real function and records how it was called, without changing it Verifying what came out of the unit, keeping the behavior sinon.spy()
Mock A stub with expectations declared up front that are verified at the end When the interaction is the behavior under test sinon.mock()
Fake A simplified but working implementation An in-memory repository, a make-believe queue Your own class, sinon.fake()

The practical distinction you will use most is stub versus spy: a spy lets you ask was this called, with which arguments and how many times? without altering anything; a stub also decides what it returns, and therefore controls the flow of the unit under test. The golden rule: stubs for the inputs (what the unit receives from the world) and spies for the outputs (what the unit does to the world).

Avoid Sinon's classic mock: its up-front expectations produce brittle tests that are hard to read. In practice, stub plus assertions at the end covers everything you need.

npm install --save-dev sinon

sinon.spy: observing without changing

A spy wraps a function and lets it do its job, recording every call.

// Standalone function: the double IS the callback
const onSaleRecorded = sinon.spy();
manager.on('sale-recorded', onSaleRecorded);
manager.recordSale(session, 2);
expect(onSaleRecorded.calledOnce).to.be.true;

// Method of an existing object: keeps the real behavior
const sellSpy = sinon.spy(session, 'sell');
session.sell(3);
expect(sellSpy.calledWith(3)).to.be.true;
expect(session.available).to.equal(297); // the real method DID run
Property / method What it checks
called, calledOnce, callCount It was called; exactly once; the exact number
calledWith(a, b) / calledWithExactly(a, b) Some call received those arguments (structural comparison), or exactly those and no others
firstCall.args, lastCall.args, getCall(n).args The arguments of one specific call
calledBefore(other), threw(), returnValues Relative order between spies; whether it threw; what it returned

A word about failure messages: expect(spy.calledWith('purchase')).to.be.true fails with expected false to be true, which tells you nothing. Prefer comparing the arguments (expect(spy.firstCall.args[0]).to.equal('purchase'), which fails with expected 'refresh' to equal 'purchase') or use Sinon's own assertions, which print the recorded calls:

sinon.assert.calledWith(spy, 'purchase', sinon.match({ userId: 'usr-001' }));

sinon.match allows partial assertions that are very useful when the argument has volatile fields: sinon.match.string, sinon.match.number, sinon.match.instanceOf(Event), sinon.match.has('appCode', 'INSUFFICIENT_CAPACITY').

sinon.stub: replacing the behavior

A stub replaces the implementation completely. Its repertoire:

const repo = { findById: () => {}, save: () => {} };

sinon.stub(repo, 'findById').returns(createEvent());   // synchronous value
sinon.stub(repo, 'findById').resolves(createEvent());  // resolved promise
sinon.stub(repo, 'save').rejects(new StateConflict('Insufficient capacity'));
sinon.stub(repo, 'save').throws(new ValidationError('Invalid quantity'));

// Different behavior per call: pure gold for testing retries
const find = sinon.stub();
find.onCall(0).rejects(new Error('ETIMEDOUT'));
find.onCall(1).rejects(new Error('ETIMEDOUT'));
find.onCall(2).resolves({ rate: 1.09 });

// Behavior driven by the arguments, with a default case
const events = { findById: sinon.stub() };
events.findById.withArgs('evt-001').resolves(createEvent());
events.findById.resolves(null);

sinon.stub(service, 'calculate').callThrough();  // lets the original through
sinon.stub(repo, 'save').callsFake(async (o) => ({ ...o, id: 'ord-999' }));

A stub is also a spy: it keeps calledOnce, firstCall.args and the whole repertoire above. That is why in practice you will almost always reach for stub.

sinon.fake is a more modern, minimalist API with the same idea (sinon.fake.returns(...), sinon.fake.resolves(...), sinon.fake.throws(...)). It is immutable — you cannot reprogram it after creating it — and that makes it more predictable; it works very well as an anonymous callback, while for replacing methods on existing objects stub is still the most convenient.

Always restore: sandboxes and contamination

Here is the hardest bug to diagnose in the whole module. sinon.stub(object, 'method') modifies the real object in memory, and in Node require caches modules: the audit.js your test sees is exactly the same object every other test in the process sees. If you do not restore it, the stub is still there in the next file.

it('records the purchase', () => {                    // test/unit/purchases.test.js
  sinon.stub(audit, 'recordAction').resolves();       // never restored
});
// In test/unit/tokens.test.js, later and in the same process,
// audit.recordAction IS STILL THE STUB from the other test.

The symptoms are unmistakable and maddening: the test passes on its own but fails in the full suite, it fails only when it runs after one particular test, or you get TypeError: Attempted to wrap recordAction which is already wrapped.

The defense is sinon.restore() in afterEach, which undoes everything created with the default sandbox (sinon.stub, sinon.spy, sinon.useFakeTimers). When you want your own scope, sinon.createSandbox() gives you a box with its own sandbox.restore(). And so that nobody can forget it, we put it in a root hook loaded from .mocharc.json:

// test/helpers/setup.js
'use strict';
const sinon = require('sinon');

// Mocha root hook: it applies to EVERY test in the project
exports.mochaHooks = {
  afterEach() {
    sinon.restore();
  },
};
{ "spec": ["test/**/*.test.js"], "recursive": true, "timeout": 5000,
  "forbidOnly": true, "require": ["test/helpers/setup.js"] }

Fake clocks

sinon.useFakeTimers() replaces Date, setTimeout, setInterval, setImmediate and process.hrtime with controlled versions. Time stops running on its own: it advances when you tell it to with clock.tick().

Case 1: the expiry of a 15-minute access token

describe('access tokens', () => {
  let clock;

  beforeEach(() => {
    clock = sinon.useFakeTimers(new Date('2026-10-01T10:00:00.000Z'));
  });

  afterEach(() => {
    clock.restore(); // ESSENTIAL: otherwise every later test lives on 2026-10-01
  });

  it('still accepts the token after 14 minutes', () => {
    const token = signAccessToken({ id: 'usr-001', role: 'attendee' });
    clock.tick(14 * 60 * 1000);
    expect(verifyAccessToken(token).id).to.equal('usr-001');
  });
  it('rejects the token after 15 minutes and one second', () => {
    const token = signAccessToken({ id: 'usr-001', role: 'attendee' });
    clock.tick(15 * 60 * 1000 + 1000);

    let caught = null;
    try {
      verifyAccessToken(token);
      expect.fail('Expected AuthenticationError for an expired token');
    } catch (error) { caught = error; }

    expect(caught).to.be.instanceOf(AuthenticationError);
    expect(caught.appCode).to.equal('TOKEN_EXPIRED');
  });
});

Two tests covering both sides of the expiry, running in under a millisecond. Without the fake clock, the second one would be literally impossible to write. It works because jsonwebtoken uses Date.now() internally and the fake clock affects the signing too; if a library used a time source Sinon does not intercept, you would have to inject the clock into it.

Case 2: the retries with waits from Module 2

Really testing retry(operation, { attempts: 3, waitMs: 1000 }) would cost two seconds per test. With the fake clock, zero:

it('retries up to three times with exponential backoff', async () => {
  const clock = sinon.useFakeTimers();
  const operation = sinon.stub();
  operation.onCall(0).rejects(new Error('ETIMEDOUT'));
  operation.onCall(1).rejects(new Error('ETIMEDOUT'));
  operation.onCall(2).resolves({ rate: 1.09 });

  const promise = retry(operation, { attempts: 3, waitMs: 1000 });
  await clock.tickAsync(1000);   // advances time AND lets the microtasks run
  await clock.tickAsync(2000);

  expect(await promise).to.deep.equal({ rate: 1.09 });
  expect(operation.callCount).to.equal(3);
  clock.restore();
});

The key is tickAsync rather than tick: tick advances the timers synchronously, but between two retries there are pending promises that need the event loop to yield, and tickAsync allows that. A warning repeated on purpose: if you do not restore the clock, every later test in the process lives in frozen time, Mocha's timeouts behave in the strangest ways, and you will spend a whole afternoon understanding nothing. The root hook with sinon.restore() covers you here too.

Faking fetch to test currency-exchange.js

This service is the perfect example of an external dependency: it calls a rate provider over HTTP, with AbortSignal.timeout, retries and an in-memory cache. We want to test its behaviors without touching the network.

/** Builds a response that looks like fetch's. */
function fakeResponse({ status = 200, body = {} } = {}) {
  return { ok: status >= 200 && status < 300, status, json: async () => body };
}

describe('currency exchange', () => {
  beforeEach(() => {
    clearCache(); // the cache is state shared between tests: it must be cleared
  });

  it('returns the rate when the provider answers correctly', async () => {
    sinon.stub(global, 'fetch').resolves(
      fakeResponse({ body: { base: 'EUR', rates: { GBP: 0.86 } } }));
    expect(await getExchangeRate('GBP')).to.equal(0.86);
  });

  it('degrades gracefully returning null when the provider answers 500', async () => {
    sinon.stub(global, 'fetch').resolves(fakeResponse({ status: 500 }));
    // A purchase must not break because the converter is down:
    // the price is shown in euros and that is that.
    expect(await getExchangeRate('GBP')).to.be.null;
  });

  it('degrades gracefully when the request times out', async () => {
    const aborted = new Error('The operation was aborted');
    aborted.name = 'AbortError';
    sinon.stub(global, 'fetch').rejects(aborted);
    expect(await getExchangeRate('GBP')).to.be.null;
  });
});

To these we add the cache one (two consecutive lookups and expect(request.calledOnce).to.be.true). Four scenarios — two of them, the 500 and the timeout, impossible to reproduce on demand against the real provider — tested in milliseconds, with no network, no quota and no flakiness.

One hygiene detail that is easy to overlook: the clearCache() in the beforeEach. The service's cache is shared state inside the same process; without clearing it, the second test would be answered from the first test's cache, fetch would never be called and the failure would be baffling. Every module with in-memory state needs a reset function designed for testing, because Sinon cannot know about it.

Faking the repository to test a controller

An Express controller is a function with three arguments: we can test it without a database and without HTTP by handing it doubles.

/** Minimal doubles for req, res and next. */
function createContext({ params = {}, user = null } = {}) {
  const res = { statusCode: null, body: null,
    status(c) { this.statusCode = c; return this; },
    json(b) { this.body = b; return this; } };
  return { req: { params, user }, res, next: sinon.spy() };
}

it('answers 200 with the serialized event', async () => {
  const repository = { findById: sinon.stub().resolves(createEvent({ id: 'evt-001' })) };
  const { req, res, next } = createContext({ params: { id: 'evt-001' } });
  await getEvent({ eventRepository: repository })(req, res, next);
  expect(repository.findById.calledWith('evt-001')).to.be.true;
  expect(res.statusCode).to.equal(200);
  expect(next.called).to.be.false;
});

it('delegates to next with ResourceNotFound when the event does not exist', async () => {
  const repository = { findById: sinon.stub().resolves(null) };
  const { req, res, next } = createContext({ params: { id: 'evt-999' } });

  await getEvent({ eventRepository: repository })(req, res, next);

  expect(next.calledOnce).to.be.true;
  expect(next.firstCall.args[0]).to.be.instanceOf(ResourceNotFound);
  expect(next.firstCall.args[0].appCode).to.equal('EVENT_NOT_FOUND');
});

The second test checks that the controller hands the error to next instead of answering itself. That is the contract that makes Module 6's errorHandler work, and it is exactly the kind of detail that breaks when somebody "simplifies" the controller.

Dependency injection versus module patching

You will have noticed the signature getEvent({ eventRepository })(req, res, next). It is no accident: it is a controller factory that receives its dependencies. Compare the two ways of writing the same thing.

// HARD TO TEST: the controller decides where it gets the data from
const { eventRepository } = require('../repositories/index.js');
async function getEvent(req, res, next) {
  const event = await eventRepository.findById(req.params.id);
}

// EASY TO TEST: a factory that receives its dependencies
function getEvent({ eventRepository }) {
  return async function handler(req, res, next) {
    try {
      const event = await eventRepository.findById(req.params.id);
      if (!event) throw new ResourceNotFound('Event not found', {
        appCode: 'EVENT_NOT_FOUND', details: { id: req.params.id } });
      res.status(200).json(event.toJSON());
    } catch (error) { next(error); }
  };
}

In the router only one line changes: router.get('/:id', getEvent({ eventRepository })). This is a small, honest refactor: the production code behaves the same, the application still wires the real repository exactly once, and in exchange the controller becomes trivially testable. That is what "tests exert design pressure" means: code that is hard to test is usually too tightly coupled, and fixing it improves the code, not just the test.

With the rigid version there is another problem: sinon.stub on the exported object may have no effect at all, because the destructuring in the require already captured the reference. When you cannot refactor — third-party code, a legacy module — there is a last resort, proxyquire, which intercepts a module's require calls:

const { getEvent } = proxyquire('../../src/controllers/events.js', {
  '../repositories/index.js': { eventRepository: { findById: sinon.stub().resolves(null) } },
});

Its drawbacks justify keeping it as a last resort: it couples the test to require paths (move a file and it breaks even though the behavior did not change), it bypasses the module cache with odd effects if there is state, it hides the design problem instead of solving it, and it does not work with ESM, so it is a dead end if you ever migrate.

When NOT to use doubles

Doubles are so convenient that it is easy to overdo it, and a test with too many of them only checks your own assumptions. Look at this gem:

// USELESS TEST: it cannot detect any real bug
it('creates the order', async () => {
  const repository = { createOrder: sinon.stub().resolves({ totalCents: 5000 }) };
  const service = createOrderService({ repository });
  const order = await service.buyTickets({ sessionId: 'ses-001-1', quantity: 2 });
  expect(order.totalCents).to.equal(5000);
});

What does this test? That a stub returning 5000 returns 5000. If buyTickets computed the total wrong, it would pass. If it never checked capacity, it would pass. If it sold negative tickets, it would pass. It costs something to maintain and it can never turn red for a real bug.

And there is a worse problem: the double can lie about the real dependency. If your repository stub returns null when it does not find the event but the real repository throws CastError on a malformed id, your unit tests will all be green while the API returns 500 in production. The double encodes what you believe the dependency does.

Situation Double it?
Network, external services, payment gateways Yes, always in unit tests
Clock, randomness, generated identifiers Yes
Scenarios that are impossible to trigger (500, timeout, full disk) Yes, it is the only way
Deliberately slow operations (bcrypt at cost 12 in a loop) Yes, with judgment
Value and domain objects (Session, Event) No, they are cheap and real
Pure logic of your own code No, never
The database in an integration test No, that is precisely what you want to test

The rule that sums it up: double the edge of the system, not its interior. And accept that unit tests with doubles, however many of them there are, do not prove the pieces fit together. The integration tests in the next lesson are the necessary counterweight; without them, doubles become a mirror in which your code admires itself.

Common Mistakes and Tips

  • Forgetting sinon.restore(). Contamination across files, already wrapped, tests that fail only in the full suite. Put the root hook in test/helpers/setup.js and forget about it.
  • Not restoring the fake clock. The whole process stays frozen in time: the most baffling failure there is.
  • Using tick where tickAsync is needed. Retries with promises never advance and the test dies by timeout.
  • Stubbing a destructured reference. const { recordAction } = require('./audit.js') captures the function; sinon.stub(audit, 'recordAction') changes the object's property, not your copy. Always call through the object.
  • expect(spy.calledWith(x)).to.be.true. A useless failure message: use sinon.assert.calledWith or compare firstCall.args.
  • Forgetting to clear in-memory state (the currency-exchange.js cache). Sinon knows nothing about it; you have to do it by hand.
  • Faking your own domain. If you are doubling Session, stop: it is cheap, deterministic and real.
  • Tip: when a stub needs more than three withArgs, consider a real fake; a small class with a Map inside is usually more readable.
  • Tip: every time you write a stub, ask yourself "which real bug would this test catch?". If you cannot answer, it is not worth having.

Exercises

Exercise 1: the year in the ticket code

generateTicketCode() produces codes of the form EV-<year>-<6 digits> with the current year and six random digits. Write deterministic tests that check: that the year in the code is the current year, freezing the clock at 2026-11-05; that the format matches the pattern exactly; and that two codes generated back to back are different.

Exercise 2: converter degradation in the controller

The events controller also shows the price in pounds when the user asks for ?currency=GBP. Write two unit tests with doubles: one in which getExchangeRate resolves 0.86 and the response includes priceGbp, and another in which it resolves null and the response does not include priceGbp but is still a correct 200.

Exercise 3: spot the useless test

Explain why this test contributes nothing and rewrite it so that it detects a real bug:

it('applies the member discount', () => {
  const calculator = { applyDiscount: sinon.stub().returns(4500) };
  expect(calculator.applyDiscount(5000, 'member')).to.equal(4500);
});

Solutions

Exercise 1

beforeEach(() => sinon.useFakeTimers(new Date('2026-11-05T12:00:00.000Z')));

it('uses the current year in the code prefix', () => {
  expect(generateTicketCode()).to.match(/^EV-2026-/);
});

it('matches the EV-<year>-<6 digits> format', () => {
  expect(generateTicketCode()).to.match(/^EV-\d{4}-\d{6}$/);
});

it('generates different codes on consecutive calls', () => {
  expect(new Set([generateTicketCode(), generateTicketCode()]).size).to.equal(2);
});

The third one is interesting: we do not freeze randomness, because what we want to check is precisely that there is variety. If you needed an exact code, you would have to stub Math.random or, better, inject the generator.

Exercise 2

it('includes priceGbp when the converter answers', async () => {
  const currencyService = { getExchangeRate: sinon.stub().resolves(0.86) };
  const repository = { findById: sinon.stub().resolves(createEvent({ id: 'evt-001' })) };
  const { req, res, next } = createContext({ params: { id: 'evt-001' }, query: { currency: 'GBP' } });

  await getEvent({ eventRepository: repository, currencyService })(req, res, next);

  expect(res.statusCode).to.equal(200);
  expect(res.body).to.have.property('priceGbp');
});

it('omits priceGbp and still returns 200 when the converter fails', async () => {
  const currencyService = { getExchangeRate: sinon.stub().resolves(null) };  // provider down
  const repository = { findById: sinon.stub().resolves(createEvent({ id: 'evt-001' })) };
  const { req, res, next } = createContext({ params: { id: 'evt-001' }, query: { currency: 'GBP' } });
  await getEvent({ eventRepository: repository, currencyService })(req, res, next);
  expect(res.statusCode).to.equal(200);
  expect(res.body).to.not.have.property('priceGbp');
  expect(next.called).to.be.false;
});

The second one is the valuable test: it guarantees that an outage at the external provider does not break the catalog. That kind of guarantee can only be obtained with doubles.

Exercise 3

The test creates a stub that returns 4500 and checks that it returns 4500: it does not run a single line of production code, so it would pass even if applyDiscount were empty. The right thing is to use the real function, which is pure and deterministic:

expect(applyDiscount(5000, 'member')).to.equal(4500);
expect(applyDiscount(5000, 'attendee')).to.equal(5000);
expect(applyDiscount(2555, 'member')).to.equal(2299);  // 2299.5 -> 2299

The third assertion is the one that really matters: rounding money is where the phantom cents that unbalance the books come from.

Conclusion

We now know how to isolate a unit from everything that makes it unpredictable. We know the taxonomy of doubles and when to use each one, we have spy and stub with their full repertoire, we understand why sinon.restore() in afterEach is not optional and how to enforce it with a root hook, we know how to freeze and advance time to test expiries and retries without waiting, we have faked fetch to cover the 500 and the timeout we could never trigger against the real provider, and we have seen that code which receives its dependencies is tested with half the effort of code that goes looking for them itself.

And we have seen the limit. Every double is an assumption about how the real piece behaves. Our tests are green, but none of them has yet checked that the routes are mounted in the right order, that errorHandler really turns a StateConflict into a 409, that Module 7's transaction prevents overselling with ten simultaneous purchases, or that authenticate actually rejects a request with no token.

In the next lesson, Integration Testing, we start the whole application with supertest and interrogate it over HTTP the way a real client would. That is where Module 6's design pays off — createApplication() returns the application without calling listen, precisely for this — and where we finally deliver on the Module 8 promise: how a test gets hold of a valid token without a single password written into the code.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved