You'll see npm test in the README of every serious JavaScript project. It runs a script in package.json that runs a tool called a test runner, which executes files ending in .test.js (or .test.ts). Each test file is a list of small functions that each say "given this input, the code I'm testing should produce this output". The runner runs them all, compares actual to expected, and reports pass/fail.
That's the whole game. This chapter is the longer version: what an automated test is, why Vitest is now the JavaScript default, the structure every test follows, what to mock and what to leave alone, and how to plug the same suite into the CI from Ch 15 so a failing test blocks the merge.
The Bug This Solves: "I Already Tested That"
Every developer has lived this loop:
- Write a function
formatPrice(cents)that returns"$12.50"for1250. - Open the browser, see it works, ship it.
- Two weeks later add a new feature that touches the same function. Forget to re-test.
- Production now shows
"$undefined.NaN"to paying customers.
Automated tests fix step 4. You wrote a test in step 1 that says formatPrice(1250) === "$12.50". Step 3, you push the change, CI re-runs the test, it fails, you fix it before merge. The merge button is greyed out — you literally cannot ship the regression.
Tests are freeze-dried QA: you do the testing once when you write the code, and the robot replays it forever. Every CI run is you re-testing every function you've ever tested, in seconds, for free.
Figure 1 — Tests are the regression net. Write once, run on every push, catch the day someone breaks a function they didn't realise was used somewhere else.
The Three Layers of the Testing Pyramid
Not every test is a unit test. The standard mental model is a pyramid of three layers, widest at the bottom:
| Layer | What it tests | Speed | How many you have |
|---|---|---|---|
| Unit tests | One function in isolation | Milliseconds each | Hundreds to thousands |
| Integration tests | A few pieces working together (e.g. a route handler + database) | Tens to hundreds of ms | Tens |
| End-to-end (E2E) | A full browser clicking through real flows | Seconds each | A handful |
The pyramid shape matters: many cheap unit tests at the bottom catch most regressions for almost no time, and a few slow E2E tests at the top confirm the whole system works.
This chapter focuses on the bottom layer — unit tests with Vitest. Integration and E2E are real chapters in their own right and most projects can ship value with unit tests alone for a long time.
Why Vitest
There are a handful of JavaScript test runners. The most cited ones:
| Runner | Status in 2026 |
|---|---|
| Vitest | The modern default. Native ESM, native TypeScript, jest-compatible API, fast. |
| Jest | The previous default. Mature, huge ecosystem. Slower than Vitest, painful with ESM + TS without a transform pipeline. |
Node's built-in node:test | Ships with Node 20+. Fine for small libs, sparse ecosystem. |
| Mocha | The old guard. Still around, mostly legacy projects. |
| Playwright Test | Different layer — it's for E2E, not unit tests. |
Use Vitest in any new JS or TS project. It uses the same describe/it/expect API that Jest does (so every example online still applies), but it's powered by Vite under the hood — fast startup, native ESM, native TypeScript, no Babel config to maintain. If you already know Jest, you already know Vitest.
Installing Vitest
In an existing project:
npm install --save-dev vitestAdd to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}That's the entire install. No vitest.config.js is required unless you want non-default behaviour.
Two scripts:
npm testruns once and exits. This is what CI uses.npm run test:watchkeeps the process alive and re-runs affected tests on file change. This is what you use while writing code.
Your First Test
Create src/format-price.js:
// src/format-price.js
export function formatPrice(cents) {
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
}And next to it, src/format-price.test.js:
// src/format-price.test.js
import { describe, it, expect } from "vitest";
import { formatPrice } from "./format-price.js";
describe("formatPrice", () => {
it("formats whole dollars", () => {
expect(formatPrice(1200)).toBe("$12.00");
});
it("formats cents", () => {
expect(formatPrice(1234)).toBe("$12.34");
});
it("formats zero", () => {
expect(formatPrice(0)).toBe("$0.00");
});
});Run npm test. You'll see:
✓ src/format-price.test.js (3)
✓ formatPrice (3)
✓ formats whole dollars
✓ formats cents
✓ formats zero
Test Files 1 passed (1)
Tests 3 passed (3)That's a working test suite. Three things to name:
describe(name, fn)— a group of related tests. Purely cosmetic; it makes the output readable. You can nest them.it(name, fn)— one individual test. (testis an alias —test()andit()are identical.) The string is what the runner prints when the test fails.expect(actual).toBe(expected)— the assertion. If they don't match, the test fails and Vitest prints a diff.
Arrange-Act-Assert — The Structure Every Test Follows
Every well-written unit test has the same three-act shape:
it("describes the behaviour", () => {
// 1. Arrange — set up the inputs and any state
const cart = [{ price: 1200 }, { price: 800 }];
// 2. Act — call the function under test
const total = sumCart(cart);
// 3. Assert — check the result
expect(total).toBe(2000);
});The pattern's name is AAA — Arrange, Act, Assert. Separating the three with a blank line each makes the test readable at a glance:
- Top: what was the input?
- Middle: what was called?
- Bottom: what should the answer be?
If your "Arrange" block is bigger than the function under test, that's a smell — the function probably has too many dependencies, or you're trying to test too much in one go.
The Two Assertion Functions You Use 95% of the Time
Vitest has dozens of toX matchers. Two cover almost everything:
expect(actual).toBe(expected); // strict equality (===)
expect(actual).toEqual(expected); // deep equality (structural)The rule:
toBefor primitives: numbers, strings, booleans,null,undefined. Also for "is this literally the same object reference".toEqualfor arrays and objects, when you want "do these have the same shape and values?".
expect(2 + 2).toBe(4); // primitive
expect({ name: "Alice" }).toEqual({ name: "Alice" }); // structural
expect({ name: "Alice" }).toBe({ name: "Alice" }); // FAILS — different object identitiesUseful extras worth knowing:
expect(value).toBeTruthy(); // !!value
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(array).toContain(item);
expect(string).toMatch(/regex/);
expect(fn).toThrow(); // fn() must throw
expect(fn).toThrow("missing arg"); // …with this message substring
expect(promise).rejects.toThrow(); // async versionYou don't need to memorise these. When you need to assert something specific, Google "vitest expect [thing]" and copy.
Testing Async Code
The vast majority of real-world functions are async (database, HTTP, file I/O). The good news: there's nothing special to learn — just mark the test async and await the function.
import { describe, it, expect } from "vitest";
import { fetchUser } from "./fetch-user.js";
describe("fetchUser", () => {
it("returns the user object", async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: "Alice" });
});
it("throws for unknown id", async () => {
await expect(fetchUser(99999)).rejects.toThrow("not found");
});
});The second test is the awkward one beginners trip on. To assert that a rejected promise threw, use expect(promise).rejects.toThrow() — note rejects (not .toThrow() on the awaited result, which would throw before you get to assert).
What to Mock and What to Leave Alone
A mock is a fake replacement for a dependency. The classic use case: your function makes an HTTP call you don't want to actually hit during a test.
// src/get-price.js
export async function getPrice(productId) {
const r = await fetch(`https://api.example.com/products/${productId}`);
const j = await r.json();
return j.price;
}You don't want every test run to hit api.example.com. So you mock fetch:
// src/get-price.test.js
import { describe, it, expect, vi } from "vitest";
import { getPrice } from "./get-price.js";
describe("getPrice", () => {
it("returns the price from the API", async () => {
// Arrange — stub global fetch
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({ price: 1999 }),
});
// Act
const price = await getPrice(42);
// Assert
expect(price).toBe(1999);
expect(fetch).toHaveBeenCalledWith("https://api.example.com/products/42");
});
});vi.fn() creates a "spy" function. mockResolvedValue says "when called, return this resolved promise". Then toHaveBeenCalledWith asserts the mock was called with specific args — that's how you check your code is constructing the URL correctly.
The mocking rule: only mock what crosses a boundary
The mistake beginners make is mocking everything. Don't. Heuristic:
- Mock things that go off-machine: HTTP, databases, file I/O, time (
new Date()), randomness (Math.random()). - Don't mock your own pure code. If
formatPricecallsdollarsToCents, just let it. Mocking your own helpers makes tests brittle (any internal refactor breaks them) without catching real bugs.
Pure functions don't need mocks at all. That's a big reason "prefer pure functions" is the advice it is — the testability falls out for free.
Coverage — What It Tells You, What It Doesn't
npm test -- --coverage (Vitest needs @vitest/coverage-v8 installed) reports code coverage: the percent of lines, branches, and functions exercised by your tests.
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
format-price.js | 100 | 100 | 100 | 100 |
get-price.js | 80 | 50 | 100 | 80 |Coverage answers "did the test runner hit this line?". It does not answer "did the test actually verify this line does the right thing?". You can have 100% coverage and zero assertions — the lines were executed, but nothing was checked.
That's why "we need 100% coverage" is a bad target. 80% with meaningful assertions is more valuable than 100% with rubber-stamped tests. Treat coverage as a smoke alarm: a sudden drop after a PR usually means somebody added a feature without testing it. The absolute number, less important.
Test Doubles in One Page
You'll hear "mock", "stub", "spy", "fake" used interchangeably. Strictly:
| Name | What it is |
|---|---|
| Stub | Returns canned values. "When called, return 42." |
| Spy | Records calls so you can assert later. "Was this called with these args?" |
| Mock | Both: returns canned values AND records calls. |
| Fake | A simplified working implementation (e.g. in-memory database). |
Vitest's vi.fn() produces a mock (does both). In practice, every team uses "mock" for all four and you can ignore the distinction unless you're in a code review where it matters.
Setup, Teardown, and Shared State
Sometimes you need to run something before every test (open a database connection) and after (close it). Vitest provides hooks:
import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("user repo", () => {
let db;
beforeEach(async () => {
db = await openTestDatabase();
});
afterEach(async () => {
await db.close();
});
it("inserts a user", async () => {
await db.insertUser({ name: "Alice" });
const users = await db.listUsers();
expect(users).toHaveLength(1);
});
});beforeEach/afterEach run for every it in their describe. There's also beforeAll/afterAll which run once for the whole block. The rule: prefer beforeEach — it gives each test a fresh world, so tests can't pollute each other.
If a test passes in isolation but fails when run with others, you have test pollution — usually shared state somebody forgot to reset.
Wiring Vitest Into CI
The CI workflow from Ch 15 has a npm test step. With Vitest installed and the test script set to vitest run, it already works. Nothing else needs to change.
# .github/workflows/ci.yml — same as Ch 15
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm test # ← Vitest runs here
- run: npm run buildIf any it() throws, the suite exits non-zero, the step is red, the PR is blocked. The flow is identical to lint and format-check — same gate mechanism, different signal.
Figure 2 — Tests in CI close the regression loop. The robot re-runs every test on every push, so a function break never reaches production without the merge button going grey first.
TDD: A Quick Word
You'll hear TDD — Test-Driven Development: write the test first, watch it fail, then write the code to make it pass.
The full ritual is overkill for most work. But the spirit — writing the test as you write the code, not days later — is what turns testing from a chore into a productivity boost. The test forces you to think about the function's interface and edge cases before you commit to an implementation. You write less throwaway code that way.
A pragmatic compromise: write the test in the same commit as the code. Not before, not next sprint. The PR diff should always have both feature.js and feature.test.js in it.
What to Test First — A Pragmatic Order
You don't need to test everything at once. Priority order for a project just adding tests:
- Pure functions with logic (price formatting, parsers, date math) — easy to test, high regression risk.
- Anything that took more than one bug to get right — if you fixed it twice, write a test so you don't fix it a third time.
- The hot path — the 3-5 functions that get called in every user request.
- Anything that integrates two systems (a DB query + a transform) — integration tests, but you'll write them as unit tests with mocks first.
- Edge cases of validation (empty inputs, max lengths, weird Unicode).
What you don't have to test:
- One-off scripts.
- Trivial getters/setters with no logic.
- React component visual rendering (a separate skill; visual regression tooling, not unit tests).
Good test coverage is built incrementally. Add a test every time you fix a bug. After a year, you have hundreds of regression nets, each one earned by a real production miss.
Mental Model — Three Sentences
- A unit test is a small function that calls a piece of your code with specific input and asserts the output:
expect(formatPrice(1200)).toBe("$12.00"). - Vitest is the modern JavaScript test runner: same
describe/it/expectAPI as Jest, native ESM and TypeScript, fast. - In CI,
npm testruns the entire suite on every push — a regression in any function turns the PR red and the merge button greys out, so broken code can't reachmain.
Try It Yourself (15 Minutes)
- In any project, install Vitest:
npm install --save-dev vitest. - Add the
testandtest:watchscripts topackage.json. - Pick one pure helper function in your codebase. Write 3-5 tests for it covering normal input, edge cases, and one error case.
- Run
npm test. Watch them pass. - Deliberately break the function. Run again. See the red diff Vitest prints.
- Run
npm run test:watchand edit the function while watching it auto-re-run. This is the dev loop you'll use 90% of the time. - Push to GitHub. Watch CI run the same suite on a fresh VM. If you've got branch protection from Ch 15, your next PR with a failing test will be unmergeable.
That's the entire workflow. Real projects have thousands of tests; the mechanics never change.
Where This Lands in the Series
Ch 16 covered the lint and format:check gates. This chapter covered the test gate. The next two chapters cover the human side of the same pipeline — opening a pull request that hits these gates, and reviewing one someone else opened.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper