Skip to main content
Back to module
Software Engineering Practices · Quality

Testing in depth

Reading51mLesson 1 of 3 in this module

What you'll learn

  • Write unit tests with fakes
  • Inject the clock and other dependencies so code can be tested deterministically
  • Choose between stubs, fakes, spies and mocks
  • Prove a test catches bugs by breaking the code on purpose

Write fast, deterministic unit tests by injecting stubs and spies, and check that your tests catch bugs.

Why it matters

Automated tests let you change code without fear. A good suite tells you in seconds that a change broke something, long before a user does. You have already written simple tests that call a function and compare the result. Real code is harder: it reads the clock, sends email, calls APIs and writes to databases. This lesson shows how to test that kind of code quickly and reliably by replacing slow or unpredictable parts with test doubles.

What makes a good unit test

A unit test checks one small piece of behaviour in isolation. Good unit tests are:

  • Fast: milliseconds each, so you run them all the time.
  • Deterministic: the same result on every run, on every machine. No real network, no real clock, no randomness you do not control.
  • Focused: one behaviour per test, with a name that says what it checks.
  • About behaviour, not implementation: they check what the function returns or does, not which private helpers it called.

Most tests follow Arrange, Act, Assert:

JavaScript
test("adds tax to the subtotal", () => {
  const cart = { subtotal: 100 };          // Arrange
  const total = totalWithTax(cart, 0.08);  // Act
  expect(total).toBe(108);                 // Assert
});

Tools such as Jest, Vitest and Node's built-in node:test provide test and expect. The ideas in this lesson apply to all of them.

Test doubles

A test double stands in for a real dependency during a test. The names are used loosely in practice, but the distinctions are useful:

DoubleWhat it doesExample
StubReturns canned answersa clock whose now() always returns 9:00
FakeA working but simplified implementationan in-memory array instead of a database
SpyRecords how it was calleda mailer that stores every message it was asked to send
MockA spy with built-in expectations that fail the test"expect send to be called once with these arguments"

You can only use a double if the code lets you swap the dependency in. That is dependency injection: pass collaborators as parameters instead of importing globals.

JavaScript
// Hard to test: uses the real clock and the real mailer.
function sendDueRemindersHard(tasks) {
  const now = new Date();
  for (const t of tasks) if (t.dueAt <= now) realMailer.send(t.owner, t.title);
}

// Easy to test: the clock and the mailer are injected.
function sendDueReminders(tasks, clock, mailer) {
  const now = clock.now();
  let sent = 0;
  for (const t of tasks) {
    if (!t.done && new Date(t.dueAt) <= now) {
      mailer.send(t.owner, "Reminder: " + t.title);
      sent += 1;
    }
  }
  return sent;
}

Now a test can build a stub clock and a spy mailer in two lines each:

JavaScript
const clock = { now: () => new Date("2026-10-23T09:00:00Z") };
const mailer = { sent: [], send(to, subject) { this.sent.push({ to, subject }); } };

const tasks = [
  { owner: "ada@example.com", title: "Pay invoice", dueAt: "2026-10-22T12:00:00Z", done: false },
  { owner: "bob@example.com", title: "Plan sprint", dueAt: "2026-10-30T12:00:00Z", done: false },
];

const count = sendDueReminders(tasks, clock, mailer);
// count === 1, and mailer.sent[0].to === "ada@example.com"

The test runs in microseconds, never emails anybody, and gives the same answer next year because the clock is frozen.

Does your test actually catch bugs?

A test that always passes is worse than no test, because it gives false confidence. One way to check a test is to break the code on purpose and see whether the test fails. Change <= to <, or delete the !t.done condition, and run the suite. If everything is still green, the test is missing a case. Automated tools do this at scale (mutation testing, for example Stryker for JavaScript), but doing it by hand on an important function is a quick, honest review of your own tests.

Edge cases worth a test of their own: an empty list, a value exactly on the boundary (due exactly now), items that should be skipped (already done), and invalid input.

Where unit tests stop

Unit tests with doubles prove that your logic is right given that the dependencies behave as the double says. They do not prove the real database or email service behaves that way. That is the job of a smaller number of integration tests (your code plus a real or containerised dependency) and a few end-to-end tests (the whole app, driven like a user). Teams often picture this as a pyramid: many unit tests, fewer integration tests, a handful of end-to-end tests.

Common mistakes

  • Mocking everything, including simple pure functions, so tests only repeat the implementation.
  • Asserting on internal details (which helper was called) instead of the result, so every refactor breaks tests.
  • Using the real clock or Math.random() in tests, causing failures that come and go ("flaky" tests).
  • Never seeing a test fail. Write it, then break the code once to prove it can.

Recap

  • Fast, deterministic, focused tests; Arrange, Act, Assert.
  • Inject dependencies so tests can pass in stubs, fakes and spies.
  • Check your tests by breaking the code on purpose.
  • Unit tests for logic, a few integration and end-to-end tests for the real wiring.

Try it

In the Do tab, build a stub clock and a spy mailer, implement reminder logic, and write a test that catches a buggy version.

Key terms (6)

test double
Any object that stands in for a real dependency during a test, such as a stub, fake, spy or mock.
stub
A test double that returns canned answers, such as a clock whose now() always returns the same time.
fake
A working but simplified implementation of a dependency, such as an in-memory array used instead of a database.
spy
A test double that records how it was called so the test can check the calls afterwards.
dependency injection
Passing collaborators such as the clock or mailer into code as parameters, so tests can swap in doubles.
mutation testing
Deliberately changing code (for example <= to <) to check that the test suite fails; tools like Stryker automate it.

Recap

  • Good unit tests are fast, deterministic and focused on behaviour; most follow Arrange, Act, Assert.
  • Inject slow or unpredictable dependencies so tests can use stubs, fakes and spies.
  • A frozen clock and a recording mailer make time-based logic testable in microseconds.
  • Break the code on purpose to confirm a test can fail, and cover boundaries and skipped items.
  • Unit tests check logic; a smaller number of integration and end-to-end tests check the real wiring.
Also in this lesson: