Testing From Zero
Unit Tests and Test Doubles
Arrange-act-assert, stubs, mocks, spies and fakes, fake timers, async errors, test isolation, and why mocking your own internals hurts.
On this page
In one line: a good unit test is small, fast, named after one behaviour, and follows arrange-act-assert. When the code under test talks to something slow or random (a database, an email service, the clock), you swap that thing for a test double: a stub, a mock, a spy or a fake. Know which is which, and do not mock your own internals.
Lesson 0 explained why we test and how to run a first test. This lesson is about the unit test itself: how to write one that is clear and stays useful, and how to control the parts around it. We use two runners: the built-in node:test and Vitest. The next lesson, Lesson 2, applies the same ideas to UI components. For a quick interview-style table of Jest and Vitest mock helpers, see Full-Stack Lesson 21. I do not repeat that table here.
What I ran / checked. Everything below was run on Node.js v22.22.0 (npm 10.9.4) on Linux, with Vitest 5.0.3 installed by npm i vitest on 2026-10-11. The node:test examples need no install. Output is pasted as printed; I removed blank lines, some stats lines and long file paths (...). Timings are from this one machine. I also read doc/api/test.md on the v22.x branch of nodejs/node. I did not read the Vitest docs text for this lesson. Where I describe Vitest behaviour, it is behaviour I saw in my own runs. Other Vitest versions may differ.
Words used in this lesson
- Arrange, Act, Assert (AAA): the three parts of a test. Set things up, do one thing, check the result.
- Dependency: something your code uses that is not part of the unit, such as a database, an email sender or the clock.
- Dependency injection: passing dependencies in as arguments instead of creating them inside the function. It lets a test pass in a replacement.
- Test double: any stand-in for a real dependency in a test, like a stunt double in a film.
- Stub: a double that returns a fixed answer.
- Mock: a double that also records how it was called, so the test can check the calls.
- Spy: a wrapper around a real function that records calls but still runs the real code.
- Fake: a double with a real, simplified working implementation, such as an in-memory database.
- Fake timers: replacements for
setTimeoutandDatethat you move forward by hand. - Test isolation: each test gives the same result alone, in any order, with no leftovers from another test.
Anatomy of a good unit test
Arrange, Act, Assert
Every test has the same three steps, in this order:
- Arrange: build the input and any doubles.
- Act: call the unit under test, once.
- Assert: check the result or the visible effect.
Here is a small function to test: a discount for a price in cents.
// discount.jsexport function applyDiscount(cents, percent) { if (!Number.isInteger(cents) || cents < 0) throw new RangeError('cents must be a non-negative integer'); if (percent < 0 || percent > 100) throw new RangeError('percent must be between 0 and 100'); return Math.round(cents * (100 - percent) / 100);}// discount.test.jsimport { describe, it } from 'node:test';import assert from 'node:assert/strict';import { applyDiscount } from './discount.js';describe('applyDiscount', () => { it('takes the percentage off the price', () => { // arrange const price = 2000; // act const result = applyDiscount(price, 25); // assert assert.equal(result, 1500); }); it('rejects a percent above 100', () => { assert.throws(() => applyDiscount(1000, 101), { name: 'RangeError', message: 'percent must be between 0 and 100', }); }); it('rejects a negative price', () => { assert.throws(() => applyDiscount(-1, 10), RangeError); });});$ node --test --test-reporter=spec discount.test.js▶ applyDiscount ✔ takes the percentage off the price (1.354688ms) ✔ rejects a percent above 100 (6.314318ms) ✔ rejects a negative price (0.349554ms)✔ applyDiscount (10.492298ms)ℹ tests 5ℹ pass 5ℹ duration_ms 195.518283describe groups tests (a "suite"). it and test are the same thing: one test. Only the first test has the // arrange comments, to show the shape. For short tests a blank line between the parts is enough.
Good names
A test name is the first thing you read when it fails. Say what behaviour is expected and under what condition, in plain words. Weak: test1, works, should call tax helper. Better: takes the percentage off the price, rejects a percent above 100, adds 20% tax to the subtotal (behaviour, not the call). The failure line should read like a sentence you can act on: applyDiscount > rejects a percent above 100. If you can guess the bug from the name alone, the name is good.
One behaviour per test
A test can have more than one assert line, but they should all check one behaviour. If a test is named "creates a user and sends an email and logs it", it has three reasons to fail. When it goes red you do not know which one broke, and the first failing assert hides the rest. Split it into three tests.
A simple check: can you name the test without the word "and"? If not, split it.
Assertions
An assertion compares the actual value with the expected value and throws if they differ. You use the one that goes with your runner: node:assert for node:test, and expect(...) for Vitest.
| Need | node:assert/strict | Vitest expect |
|---|---|---|
Same primitive (===) | assert.equal(a, b) | expect(a).toBe(b) |
| Same content in objects and arrays | assert.deepEqual(a, b) | expect(a).toEqual(b) |
| Throws | assert.throws(fn, matcher) | expect(fn).toThrow('text') |
| Promise rejects | await assert.rejects(p, matcher) | await expect(p).rejects.toThrow('text') |
Two reminders:
- Import the strict version:
import assert from 'node:assert/strict'. The older looseassert.equalcompares with==, which says'1' == 1is true. Strict mode compares with===. - A failing assertion should show a diff. Vitest prints
- Expectedand+ Receivedlines for objects (toEqual({ price: 1500, currency: 'USD' })againstcurrency: 'EUR'printed a- "currency": "USD",line and a+ "currency": "EUR",line). I ran this. Prefer one precise assertion on the whole value (toEqual({...})) over many loose ones, to get that full diff.
node:test and Vitest side by side
You will meet both. They teach the same ideas.
node:test | Vitest | |
|---|---|---|
| Install | none (built into Node) | npm i -D vitest (I used 5.0.3) |
| Run | node --test | npx vitest (watch) or npx vitest run (once) |
| Files found | **/*.test.{js,mjs,cjs}, *-test.*, *_test.* (from the Node docs) | files named *.test.* ran in my setup |
| Assertions | node:assert | expect (Jest-style) |
| Mocks | mock.fn, mock.method, mock.timers | vi.fn, vi.spyOn, vi.useFakeTimers |
| Best for | libraries and Node code with few dependencies | apps that use Vite, TypeScript, JSX, browser-like tests |
A Vitest setup for plain Node code needs only npm i -D vitest and, optionally, a vitest.config.js with test: { environment: 'node' }. My run printed RUN v5.0.3, Tests 5 passed (5).
Test doubles: stub, mock, spy, fake
Why doubles? Say you test a sign-up function. If it sends a real email and writes to a real database, the test is slow, can fail because of the network, and spams real people. A double replaces the slow or random part.
The code we test accepts its dependencies as an argument. This is dependency injection, the simplest way to make code testable:
// signup.jsexport async function signUp(email, { users, mailer, now = () => new Date() }) { if (!email.includes('@')) throw new Error('invalid email'); if (await users.findByEmail(email)) throw new Error('email already used'); const user = await users.save({ email, createdAt: now().toISOString() }); await mailer.send(email, 'Welcome!'); return user;}The four kinds, with one definition each:
| Double | Definition | Use it when you want... |
|---|---|---|
| Stub | Returns a canned answer. The test does not care how it was called. | to control an input (a fixed time, a user that exists) |
| Mock | Records calls, and the test asserts on the calls. | to check an effect that has no return value ("an email was sent once") |
| Spy | Wraps a real function. It records calls and still runs the real code. | to watch a real thing without replacing it |
| Fake | A working simplified implementation (like an in-memory database). | realistic behaviour without the real, slow thing |
Words are used loosely in the wild. Many libraries call everything "mock" (vi.fn() can serve as a stub, a mock or a spy depending on how you use it). What matters is what the test does with it: only feeds an answer (stub), asserts on calls (mock), watches real code (spy), or runs a mini version (fake).
The same ideas with node:test
// signup.node.test.jsimport { describe, it, mock } from 'node:test';import assert from 'node:assert/strict';import { signUp } from './signup.js';// FAKE: a small working version (in memory) of the real thingfunction fakeUsers() { const rows = []; return { rows, async findByEmail(email) { return rows.find((r) => r.email === email) ?? null; }, async save(u) { const row = { id: rows.length + 1, ...u }; rows.push(row); return row; }, };}describe('signUp', () => { it('saves the user with the time from the clock', async () => { const users = fakeUsers(); // STUB: returns a fixed answer, we never check how it was called const now = () => new Date('2026-01-02T03:04:05Z'); const mailer = { send: async () => {} }; const user = await signUp('a@b.com', { users, mailer, now }); assert.deepEqual(user, { id: 1, email: 'a@b.com', createdAt: '2026-01-02T03:04:05.000Z' }); }); it('sends one welcome email (MOCK: we check the call)', async () => { const send = mock.fn(async () => {}); await signUp('a@b.com', { users: fakeUsers(), mailer: { send } }); assert.equal(send.mock.callCount(), 1); assert.deepEqual(send.mock.calls[0].arguments, ['a@b.com', 'Welcome!']); }); it('wraps a real method with a SPY (still runs the real code)', async () => { const users = fakeUsers(); const spy = mock.method(users, 'save'); // calls through by default await signUp('a@b.com', { users, mailer: { send: async () => {} } }); assert.equal(spy.mock.callCount(), 1); assert.equal(users.rows.length, 1); // the real save ran too }); it('refuses a duplicate email and sends nothing', async () => { const users = fakeUsers(); const send = mock.fn(async () => {}); await signUp('a@b.com', { users, mailer: { send } }); send.mock.resetCalls(); await assert.rejects(signUp('a@b.com', { users, mailer: { send } }), /email already used/); assert.equal(send.mock.callCount(), 0); });});$ node --test --test-reporter=spec signup.node.test.js▶ signUp ✔ saves the user with the time from the clock (3.040459ms) ✔ sends one welcome email (MOCK: we check the call) (0.727281ms) ✔ wraps a real method with a SPY (still runs the real code) (2.068952ms) ✔ refuses a duplicate email and sends nothing (0.627017ms)✔ signUp (8.059054ms)ℹ tests 4ℹ pass 4ℹ fail 0Look at which double plays which role:
fakeUsers()is a fake. It really finds and saves, just in an array.nowis a stub. It always returns the same date, so the test can check an exactcreatedAt.mock.fn()forsendis a mock. The test asserts it was called once with the right arguments.mock.method(users, 'save')is a spy. By default it still calls the realsave, which is whyusers.rows.lengthis 1.
The same ideas with Vitest
The roles are the same, with different names. I ran all of these in one file (signup.vitest.test.js, 5 tests passed in Vitest 5.0.3):
| Role | node:test | Vitest (as I used it) |
|---|---|---|
| Mock | mock.fn(), send.mock.callCount() | vi.fn().mockResolvedValue(undefined), expect(send).toHaveBeenCalledWith('a@b.com', 'Welcome!') |
| Spy | mock.method(users, 'save') | vi.spyOn(users, 'save'), then expect(spy).toHaveBeenCalledOnce() |
| Stub | a plain function | vi.fn().mockResolvedValue({ id: 9 }) for findByEmail, then expect(users.save).not.toHaveBeenCalled() |
Note the stub test: findByEmail returns a fixed answer (a stub), and the last line checks that save was not called (a mock-style check on another double). One test can mix roles.
Which double should I pick?
From most to least preferred: the real thing if it is fast and has no side effects; a fake for realistic behaviour without the real system; a stub to control an input (time, random, a response); a mock only when the effect is the point ("an email is sent"). Heavy use of mocks tends to describe how the code works instead of what it does (see the end of this lesson).
Fake timers
Code that waits (setTimeout, debounce, retries, polling) is slow and flaky to test with real time. Fake timers replace the real timers with a clock you move by hand.
A debounce delays a call until there has been a quiet period. This is debounce.js (the real file I ran):
export function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };}With node:test:
// debounce.node.test.jsimport { it, mock } from 'node:test';import assert from 'node:assert/strict';import { debounce } from './debounce.js';it('calls once, after the quiet period', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const fn = mock.fn(); const d = debounce(fn, 300); d('a'); d('b'); d('c'); t.mock.timers.tick(299); assert.equal(fn.mock.callCount(), 0); // not yet t.mock.timers.tick(1); assert.equal(fn.mock.callCount(), 1); assert.deepEqual(fn.mock.calls[0].arguments, ['c']); // last call wins});$ node --test --test-reporter=spec debounce.node.test.js(node:4676) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time(Use `node --trace-warnings ...` to show where the warning was created)✔ calls once, after the quiet period (2.737911ms)ℹ tests 1ℹ pass 1ℹ fail 0The warning is real: in the Node v22 docs, MockTimers is marked Stability 1 - Experimental, while the test runner itself is marked Stable. That matters if you depend on it for a long-lived project.
With Vitest the calls are vi.useFakeTimers() (in beforeEach), vi.advanceTimersByTime(299), vi.setSystemTime(new Date('2026-05-06T07:08:09Z')) to fake the date, and vi.useRealTimers() (in afterEach). Both Vitest timer tests passed in the run above (a debounce test and a date test, a few milliseconds each). Always put the real clock back: if you leave the fake clock on, a later test can hang.
Testing errors and async code
Errors. Test the error that a bad input causes, with the specific message or type, not just "it throws". The assert.throws examples earlier use an object matcher (name and message). A bare assert.throws(fn) passes for any error, including a typo in your own test.
Async code. Return or await the promise. If you forget, the test ends before the check runs. Here is the trap in node:test, run for real:
// forgot.node.test.jsimport { it } from 'node:test';import assert from 'node:assert/strict';const neverFails = async () => 'fine';it('forgot await: passes by accident', () => { assert.rejects(neverFails()); // no await, no return});it('with await: fails as it should', async () => { await assert.rejects(neverFails());});$ node --test --test-reporter=spec forgot.node.test.js✔ forgot await: passes by accident (1.477662ms)✖ with await: fails as it should (0.506999ms)ℹ Error: Test "forgot await: passes by accident" ... generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Missing expected rejection." and would have caused the test to fail, but instead triggered an unhandledRejection event.The first test is green although the thing it checks is false. The runner warns about it afterwards, but a green mark on a broken check is how weak tests are born. Rule: every async assertion is awaited or returned. In Vitest, write await expect(promise).rejects.toThrow(...).
Test isolation
Tests must not depend on each other. Each one should pass alone, in any order, and leave nothing behind. The usual enemies are module-level state (a variable, cache or set in a file), a shared database or file, a mock or fake timer that is not restored, and tests that rely on running in a set order.
Here is code with hidden shared state:
// counter.jsexport const seen = new Set(); // module-level state: shared by every test in the fileexport function firstVisit(id) { if (seen.has(id)) return false; seen.add(id); return true;}// counter.test.jsimport { it, expect } from 'vitest';import { firstVisit } from './counter.js';it('first time is true', () => { expect(firstVisit('u1')).toBe(true); });it('second time is false', () => { expect(firstVisit('u1')).toBe(false); });$ npx vitest run counter.test.js Tests 2 passed (2)$ npx vitest run counter.test.js -t "second time" (the second test alone)AssertionError: expected true to be false // Object.is equality Tests 1 failed | 1 skipped (2)Shuffling with --sequence.shuffle --sequence.seed=3 made both tests fail in my run.
The suite is green in normal order, and fails when a test runs alone or in a different order. This is a hidden order dependency, and it is a classic source of flaky tests in CI, where tests may run in another order or in parallel. Two habits find it early: run a single test by name now and then, and shuffle the order (--sequence.shuffle) in a CI job.
Fixes: avoid module state, or create fresh state per test with beforeEach (available in both runners), for example beforeEach(() => { users = fakeUsers(); }). Be careful what reset helpers do: Full-Stack Lesson 21 records what restoreAllMocks and clearAllMocks do in current Vitest.
Why mocking your own internals hurts
A mock replaces something. If it replaces your own inner function, the test now knows how your code is built, not only what it does. Then a harmless refactor turns it red. Look:
// order.jsexport const helpers = { tax(cents) { return Math.round(cents * 0.2); },};export function orderTotal(cents) { return cents + helpers.tax(cents);}// order.test.jsimport { it, expect, vi } from 'vitest';import { orderTotal, helpers } from './order.js';it('MOCKS OWN INTERNAL: knows how the total is built', () => { const spy = vi.spyOn(helpers, 'tax').mockReturnValue(20); expect(orderTotal(100)).toBe(120); expect(spy).toHaveBeenCalledWith(100);});it('BEHAVIOUR: checks the result only', () => { expect(orderTotal(100)).toBe(120);});$ npx vitest run order.test.js Tests 2 passed (2)Now inline the tax. The answer for every input is the same:
// order.js (refactored)export const helpers = {};export function orderTotal(cents) { return Math.round(cents * 1.2);}$ npx vitest run order.test.js FAIL order.test.js > MOCKS OWN INTERNAL: knows how the total is builtError: The property "tax" is not defined on the object. Tests 1 failed | 1 passed (2)Same behaviour, one red test. The mocked-internals test also never tested the real tax calculation, because the mock replaced it with a fixed 20. So it was both brittle and weak.
A good rule for what to double:
- Double the edges of your system: the network, the database, the clock, randomness, the file system, the email provider, payment APIs. These are slow, random or have side effects.
- Do not double your own pure logic. Call it for real.
- If you feel you must mock an internal, that is a design hint: the unit may be doing too much. Pull the logic into a function you can test directly with plain input and output.
Also, a double can drift away from the real thing: your mock says the API returns { id } and the real API changed to { userId }. The test stays green and production breaks. That is why Lesson 3 adds integration tests with real pieces, and why a fake is safer than a hand-written mock when you can afford one.
Try it
- Pick the double. For each, say stub, mock, spy or fake: (a) a function that returns the fixed date
2026-01-02; (b) a function that records it was called with'Welcome!'; (c) an in-memory array that really saves and finds users; (d) a wrapper around the realsavethat counts calls. Answer: (a) stub, (b) mock, (c) fake, (d) spy. - Find the bug. What is wrong with
it('rejects', () => { expect(failingAsync()).rejects.toThrow('boom'); })? Answer: the promise is neitherawaited nor returned, so the test can finish before the check runs. Writeawait expect(...).rejects.toThrow('boom')in anasynctest.
Interview questions
What makes a good unit test? (Must know)
It is fast, tests one behaviour, has a clear name, follows arrange-act-assert and does not depend on other tests. When it fails, the name and the diff tell me what broke without opening the code. It checks results a caller can see, not how the inside is built.
What is the difference between a stub, a mock, a spy and a fake? (Must know)
A stub returns a canned answer so I can control an input. A mock records calls and the test asserts on those calls. A spy wraps a real function: it records calls but still runs the real code. A fake is a simple but really working version, like an in-memory repository. I add that libraries often call everything a "mock", so I describe by what the test does with the double.
Follow-up: "Which one do you prefer?" I prefer the real thing, then a fake, then a stub. I use a mock only when the effect itself is the behaviour, such as "an email is sent".
How do you test code that uses the current time or setTimeout? (Must know)
I do not wait in real time. Either I inject the clock (pass a now() function so the test passes a fixed date) or I use fake timers (vi.useFakeTimers() in Vitest, t.mock.timers in node:test) and move time by hand. I always restore real timers afterwards. I ran a debounce test this way and it finished in a few milliseconds instead of 300 ms.
How do you test a function that throws or returns a rejected promise? (Must know)
I assert the specific error (type and message), not just "it throws". For async code I await the assertion: await expect(p).rejects.toThrow('...'). If I forget the await, the test can pass while the check never runs. I saw this in node:test, where a missing await gave a green test for a false claim.
Why is mocking your own internal functions a problem? (Senior signal)
It couples the test to the structure of the code. A refactor that keeps behaviour will break the test, and the mock hides the real logic, so it also checks less. In my example, inlining a helper made one test fail while a test of the public result stayed green. I double the edges (network, database, clock, randomness) and call my own pure logic for real. If I want to mock an internal, I treat it as a hint that the unit does too much.
What is test isolation, and what breaks it? (Senior signal)
Each test should give the same result alone, in any order, and leave nothing behind. State shared at module level, a shared database or file, unrestored mocks or fake timers, and tests that depend on order all break it. I showed a Set at module level: the file passed in order, but failed when a single test was run or when order was shuffled. I run single tests and shuffle order in CI to find these early.
node:test or Vitest? (Senior signal)
For a Node library or script with few dependencies I like node:test: nothing to install, Stable, and quick to start. For an app that uses Vite, TypeScript or JSX, or when I need a browser-like environment and rich matchers, Vitest fits better because it works with the same Vite setup as the app. The ideas (AAA, doubles, isolation) are the same in both. One honest caveat: Node's fake timers were marked experimental in v22.
Your tests pass locally but one fails sometimes in CI. Where do you look first? (Senior signal)
At isolation and time. I look for shared state or order dependence (run it alone and shuffled), real timers or real dates (use fake ones), real network calls (replace them), and shared files or ports when tests run in parallel. Only after that do I look at the environment. Lesson 4 goes deeper on flaky tests.
Key takeaways
- Write tests as arrange, act, assert. One behaviour per test, with a name that reads like a sentence.
- Use strict assertions (
node:assert/strict, ortoBeandtoEqualin Vitest). Check specific errors, not just "throws". - Stub = fixed answer. Mock = checks the calls. Spy = watches a real function. Fake = a small working version. Prefer real, then fake, then stub, and use mocks rarely.
- Use fake timers or an injected clock for time. Restore them after each test.
- Always
awaitorreturnasync assertions. - Keep tests isolated. Shuffle order and run single tests to find hidden dependencies.
- Double the edges of your system, not your own logic. Mocked internals make tests brittle and weak.
- Versions used: Node v22.22.0, Vitest 5.0.3. In Node v22
node:testis Stable and its mock timers are Experimental.
What's next
Next: Testing UI Components with Testing Library, Lesson 2. Practise first: pick a function in your project that sends something (an email, a request) and write one test with a fake for storage and a mock for the sending.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series