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:
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:
| Double | What it does | Example |
|---|---|---|
| Stub | Returns canned answers | a clock whose now() always returns 9:00 |
| Fake | A working but simplified implementation | an in-memory array instead of a database |
| Spy | Records how it was called | a mailer that stores every message it was asked to send |
| Mock | A 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.
// 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:
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.