Unit Testing with Vitest

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Vitest turns the tiny testing pattern from the last lesson into a real project workflow. You still write executable examples with Arrange, Act, Assert, but now a test runner finds the files, reports failures clearly, and reruns tests while you edit.

This lesson is about the daily shape of unit testing: describe, it, expect, matchers, watch mode, and coverage. UI testing, mocks, fake timers, and network boundaries come next.

Put Vitest behind npm scripts

In a real project, you normally install Vitest as a development dependency and expose it through package.json scripts:

npm install -D vitest

The useful part of the manifest then looks like this:

{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest --watch",
    "coverage": "vitest run --coverage"
  }
}

Use npm test for a one-shot run. Use npm run test:watch while you are actively changing code. Use the coverage script when you want a report about which statements, branches, functions, and lines ran during the test suite.

The point is simple: tests belong in the same project contract as dev, lint, format, typecheck, and build.

A unit test file

Keep unit tests close to the behavior they check. A common naming pattern is thing.test.js next to thing.js:

src/
  cart.js
  cart.test.js

Here is the code under test:

export function subtotalCents(items) {
  return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
}
 
export function qualifiesForFreeShipping(totalCents) {
  return totalCents >= 5000;
}
 
export function normalizeSku(sku) {
  return sku.trim().toUpperCase();
}
 
export function parseQuantity(value) {
  const quantity = Number(value);
 
  if (!Number.isInteger(quantity) || quantity <= 0) {
    throw new Error("Quantity must be a positive integer");
  }
 
  return quantity;
}

And here is a matching Vitest file:

import { describe, expect, it } from "vitest";
import { qualifiesForFreeShipping, subtotalCents } from "./cart.js";
 
describe("subtotalCents", () => {
  it("adds price times quantity for each item", () => {
    const cart = [
      { name: "Notebook", priceCents: 1299, quantity: 2 },
      { name: "Pen", priceCents: 399, quantity: 1 },
    ];
 
    const total = subtotalCents(cart);
 
    expect(total).toBe(2997);
  });
 
  it("returns zero for an empty cart", () => {
    const cart = [];
 
    const total = subtotalCents(cart);
 
    expect(total).toBe(0);
  });
});
 
describe("qualifiesForFreeShipping", () => {
  it("starts free shipping at 5000 cents", () => {
    expect(qualifiesForFreeShipping(5000)).toBe(true);
  });
 
  it("rejects totals below 5000 cents", () => {
    expect(qualifiesForFreeShipping(4999)).toBe(false);
  });
});

Read the three Vitest words directly:

  • describe(...) groups related tests. It is for organization and reporting.
  • it(...) defines one test case. The first argument should be a sentence about behavior.
  • expect(...) starts an assertion. The matcher after it says what must be true.

The first test still has Arrange, Act, Assert. Arrange creates the cart. Act calls subtotalCents. Assert checks the total with expect(total).toBe(2997).

Run the suite:

npm test
 
# ✓ src/cart.test.js (4 tests)
# Test Files  1 passed (1)
# Tests       4 passed (4)

If qualifiesForFreeShipping accidentally changes from >= 5000 to > 5000, the sentence points at the broken contract:

npm test
 
# FAIL src/cart.test.js > qualifiesForFreeShipping > starts free shipping at 5000 cents
# AssertionError: expected false to be true

That is the value of sentence names. The runner tells you which behavior changed.

Matchers say what kind of check you mean

The matcher is the method after expect(...). Vitest includes a matcher library so your assertions can say the right kind of equality.

import { describe, expect, it } from "vitest";
import { normalizeSku, parseQuantity, qualifiesForFreeShipping } from "./cart.js";
 
describe("cart matchers", () => {
  it("uses matchers that fit the value being checked", () => {
    expect(normalizeSku(" pen-01 ")).toBe("PEN-01");
    expect(["Notebook", "Pen", "Pencil"]).toContain("Pen");
    expect({ cents: 1299, currency: "USD" }).toEqual({
      cents: 1299,
      currency: "USD",
    });
    expect(() => parseQuantity("0")).toThrow("positive integer");
    expect(qualifiesForFreeShipping(4999)).not.toBe(true);
  });
});

Use toBe for primitives such as numbers, strings, booleans, null, and undefined. For objects and arrays, toBe checks whether both sides are the same object identity. Use toEqual when you mean "same structure and values."

The other common matchers read almost like English:

  • toContain checks that an array or string includes a value.
  • toThrow checks that a function throws an error.
  • .not reverses the matcher.

Choose the matcher that explains the behavior. A vague assertion can pass while hiding the real contract.

Watch mode is the feedback loop

Watch mode keeps Vitest running. It runs the tests, waits for file changes, and reruns affected tests as you edit.

npm run test:watch
 
# ✓ src/cart.test.js (4 tests)
# Test Files  1 passed (1)
# Tests       4 passed (4)
# Waiting for file changes...

This is where unit tests feel less like paperwork and more like a live safety net. You save src/cart.js, the relevant tests rerun, and the terminal tells you whether the behavior still holds.

A useful local rhythm:

  • run the app with npm run dev
  • keep tests open with npm run test:watch
  • make one small change
  • read the first failing sentence if something breaks
  • fix the behavior or update the test if the intended contract genuinely changed

Use the one-shot npm test in CI or before pushing. Use watch mode while thinking.

Coverage is a map, not a verdict

Coverage reports which parts of your code ran during tests. It can show statements, branches, functions, and lines.

npm run coverage
 
# File      | % Stmts | % Branch | % Funcs | % Lines
# cart.js   |   92.30 |    75.00 |  100.00 |   92.30
# All files |   92.30 |    75.00 |  100.00 |   92.30

Coverage is useful because it reveals blind spots. If a risky validation branch has 0% branch coverage, no test has exercised it. If a file sits at 0% line coverage, your test suite never loaded it.

Coverage does not tell you whether the tests are meaningful. This test can run a line and assert nothing:

import { it } from "vitest";
import { subtotalCents } from "./cart.js";
 
it("calls subtotalCents", () => {
  subtotalCents([{ priceCents: 100, quantity: 2 }]);
});

That test may improve a coverage number while protecting no behavior. A better test names the expected result and checks it.

Treat coverage as a map for questions:

  • Which important paths have no tests?
  • Did the bug-fix test actually hit the bug path?
  • Are we only testing the easy success case?
  • Is a high percentage hiding weak assertions?

Do not chase 100% as a trophy. A lower number with sharp tests can be more valuable than a perfect number padded with empty calls.

Keep unit tests small

Unit tests are strongest when they avoid the browser, the network, clocks, storage, and large UI flows. Put important rules in functions that accept data and return data, then test those functions directly.

That does not mean real apps are only pure functions. It means you choose the cheapest honest test for each behavior. Unit tests cover core logic. Integration and UI tests cover wiring. End-to-end tests cover a few critical whole-user journeys.

Next, you will move one layer up the pyramid: testing what the user sees, and using mocks only where a boundary would make the test slow, flaky, or hard to control.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion