Tutorials Ultimate Web Development Series Chapter 17

Unit Testing with Vitest — Your First Real Test Suite

WebChapter 17 of the Ultimate Web Development Series28 minMay 27, 2026Beginner

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:

  1. Write a function formatPrice(cents) that returns "$12.50" for 1250.
  2. Open the browser, see it works, ship it.
  3. Two weeks later add a new feature that touches the same function. Forget to re-test.
  4. 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.

Loading diagram…

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:

LayerWhat it testsSpeedHow many you have
Unit testsOne function in isolationMilliseconds eachHundreds to thousands
Integration testsA few pieces working together (e.g. a route handler + database)Tens to hundreds of msTens
End-to-end (E2E)A full browser clicking through real flowsSeconds eachA 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:

RunnerStatus in 2026
VitestThe modern default. Native ESM, native TypeScript, jest-compatible API, fast.
JestThe previous default. Mature, huge ecosystem. Slower than Vitest, painful with ESM + TS without a transform pipeline.
Node's built-in node:testShips with Node 20+. Fine for small libs, sparse ecosystem.
MochaThe old guard. Still around, mostly legacy projects.
Playwright TestDifferent 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 vitest

Add 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:

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:

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:

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:

expect(2 + 2).toBe(4);                             // primitive
expect({ name: "Alice" }).toEqual({ name: "Alice" }); // structural
expect({ name: "Alice" }).toBe({ name: "Alice" });    // FAILS — different object identities

Useful 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 version

You 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:

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:

NameWhat it is
StubReturns canned values. "When called, return 42."
SpyRecords calls so you can assert later. "Was this called with these args?"
MockBoth: returns canned values AND records calls.
FakeA 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 build

If 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.

Loading diagram…

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:

  1. Pure functions with logic (price formatting, parsers, date math) — easy to test, high regression risk.
  2. 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.
  3. The hot path — the 3-5 functions that get called in every user request.
  4. Anything that integrates two systems (a DB query + a transform) — integration tests, but you'll write them as unit tests with mocks first.
  5. Edge cases of validation (empty inputs, max lengths, weird Unicode).

What you don't have to test:

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

  1. 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").
  2. Vitest is the modern JavaScript test runner: same describe/it/expect API as Jest, native ESM and TypeScript, fast.
  3. In CI, npm test runs 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 reach main.

Try It Yourself (15 Minutes)

  1. In any project, install Vitest: npm install --save-dev vitest.
  2. Add the test and test:watch scripts to package.json.
  3. Pick one pure helper function in your codebase. Write 3-5 tests for it covering normal input, edge cases, and one error case.
  4. Run npm test. Watch them pass.
  5. Deliberately break the function. Run again. See the red diff Vitest prints.
  6. Run npm run test:watch and edit the function while watching it auto-re-run. This is the dev loop you'll use 90% of the time.
  7. 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.

Ch 16: ESLint and PrettierCh 18: Pull Requests
Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.Astro + Next.jsAstro & Next.js SeriesStatic and hybrid web app patterns with Astro, Next.js, MDX, dynamic routes, and Cloudflare deploys.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

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
5 free articles remainingSubscribe for unlimited access