Unit tests
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Small, fast, isolated tests of a single function (or component) — the foundation of the testing pyramid because they’re cheap to write, instant to run, and pinpoint exactly where a bug lives.
In plain English
A unit test exercises ONE piece of your code — usually a single function — and verifies that given specific inputs, it produces specific outputs. Nothing else is involved: no real database, no real network calls, no real UI. Just “call this function with these arguments, expect this result.”
import { describe, it, expect } from "vitest";
import { add } from "./math";
it("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
});That’s a unit test. It runs in microseconds. If add breaks, this test fails immediately and tells you exactly where.
The word “unit” is doing a lot of work here. What counts as one unit?
- A pure function (clearly one unit)
- A class method (one unit)
- A React component rendered in isolation (one unit, by most definitions)
- A small module of related utilities (sometimes considered one unit)
The size of “a unit” varies by team and by what’s practical to test in isolation. The principle is: the smallest cohesive piece of behavior you can test without dragging the rest of the system along.
Why isolation matters: if add(2, 3) fails its test, you KNOW the bug is in add. If a test that exercises 20 functions fails, you have to debug all 20. Unit tests buy you precision.
Why it matters
Three concrete reasons unit tests dominate the testing pyramid:
-
They’re fast. A whole suite of 500 unit tests runs in under a second. You can run them on every keystroke, on every save, on every commit — never noticing the wait.
-
They pinpoint bugs. A failing unit test names exactly which function broke. Compare with a failing E2E test (“login flow broken”) that could be 100 different things.
-
They’re easy to write. Once your codebase has some testable functions (small, pure, with clear inputs/outputs), writing a unit test is a 60-second exercise. Much cheaper than spinning up a browser.
The trade-off: a green unit test suite doesn’t prove the system works END-TO-END. Each function might work in isolation, but their composition could be broken. That’s what integration and E2E tests are for. Unit tests are necessary, not sufficient.
The tools for JavaScript / TypeScript in 2026
| Tool | Notes |
|---|---|
| Vitest | The modern default. Fast, ESM-native, Vite-integrated, near-Jest-compatible API. What new Next.js / Vite / Astro projects use. |
| Jest | The decade-long incumbent. Still common in older codebases. Slowly being replaced by Vitest. |
| Bun’s built-in test runner | Zero-config, very fast. Works if you’re already using Bun. |
Node’s built-in node:test | Available since Node 20. Minimal but real. Good for libraries that want zero deps. |
| Mocha + Chai | Old guard. Still works; still has a following in some Node.js circles. |
| AVA | Concurrent, minimal. Niche but loyal users. |
For a Next.js + Vite project in 2026, Vitest is the unambiguous default. It’s what new tutorials use, what most maintained libraries assume, and what AI tools default to generating.
For React components specifically, pair Vitest with React Testing Library (RTL) — a small library that lets you render components and query the resulting DOM.
A concrete example: testing a utility function
// lib/format.ts
export function formatCurrency(amount: number, currency = "USD"): string {
if (Number.isNaN(amount)) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}// lib/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./format";
describe("formatCurrency", () => {
it("formats positive USD amounts", () => {
expect(formatCurrency(42)).toBe("$42.00");
});
it("formats AUD amounts when specified", () => {
expect(formatCurrency(42, "AUD")).toBe("A$42.00");
});
it("formats zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("formats negative amounts", () => {
expect(formatCurrency(-10)).toBe("-$10.00");
});
it("returns an em dash for NaN", () => {
expect(formatCurrency(NaN)).toBe("—");
});
it("handles fractional cents", () => {
expect(formatCurrency(42.5)).toBe("$42.50");
});
it("rounds half-cents", () => {
expect(formatCurrency(42.995)).toBe("$43.00");
});
});Run with npx vitest. All seven tests pass in milliseconds. If someone “improves” formatCurrency and accidentally breaks NaN handling, test 5 fails and tells them exactly which line.
Notice the pattern:
- One
describeblock per function - One
itper behavior (not per input!) —it("rounds half-cents")is more useful thanit("works") - Test boundary cases (zero, negative, NaN)
- Test different inputs that exercise different code paths
A concrete example: testing a React component
For a small React component:
// components/Counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<div>
<span aria-label="count">{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(initial)}>Reset</button>
</div>
);
}// components/Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
it("starts at the initial value", () => {
render(<Counter initial={5} />);
expect(screen.getByLabelText("count")).toHaveTextContent("5");
});
it("increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByLabelText("count")).toHaveTextContent("1");
});
it("resets to the initial value", async () => {
const user = userEvent.setup();
render(<Counter initial={10} />);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByLabelText("count")).toHaveTextContent("10");
});
});Notes:
- Query by user-visible attributes, not by CSS class or test ID.
getByRole("button", { name: "Increment" })is what users see. CSS classes can change without breaking the test. - Use
userEventnotfireEvent—userEventsimulates real user interactions (clicks, focus, typing);fireEventfires raw DOM events and misses keyboard accessibility. - One assertion per test, ideally. Helps you know exactly what failed.
- No mounting the whole app. The Counter is rendered in isolation. Other components, contexts, routes don’t matter here.
The Arrange–Act–Assert pattern
Almost every unit test follows the same three-phase shape:
it("does the thing", () => {
// Arrange — set up state, inputs, mocks
const user = { id: "1", name: "George" };
const cart = { items: [{ price: 50 }] };
// Act — invoke the code under test
const result = applyDiscount(user, cart, 0.1);
// Assert — check the outcome
expect(result.total).toBe(45);
});Reading a test should feel like reading a story: setup, action, result. Tests that mix these phases (set up some state, act, set up more state, act again, check both) are harder to understand and harder to debug.
For tests with multiple acts, consider splitting them into multiple it blocks.
Mocks, stubs, spies — when isolation needs help
A “pure” unit takes inputs and returns outputs with no external dependencies. Real-world code often isn’t pure: a function calls a database, sends an email, reads from a service.
To unit-test such functions, you mock the external dependency:
import { describe, it, expect, vi } from "vitest";
import { sendInvoice } from "./invoice";
it("emails the customer after sending an invoice", async () => {
const sendEmail = vi.fn(); // a mock function — records calls, returns undefined
await sendInvoice({ customerId: "1", amount: 100 }, { sendEmail });
expect(sendEmail).toHaveBeenCalledOnce();
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ subject: "Invoice for $100" })
);
});The terminology gets fuzzy. Pragmatically:
- Mock — a fake replacement that records how it was called
- Stub — a fake replacement that returns canned values
- Spy — wraps a real function so you can see how it was called
- Fake — a working alternative implementation (e.g. an in-memory database)
Vitest’s vi.fn(), vi.spyOn(), vi.mock() cover all four roles. Jest’s API is nearly identical.
The mocking trade-off: mocks make tests faster and more isolated, but they’re a lie. Your test asserts “we called sendEmail correctly” — not “the email was actually sent.” Mocks can lull you into a false sense of correctness. Use them when isolation truly matters; prefer testing the real thing when possible.
Testing async code
Modern Node + Vitest handles async naturally:
it("resolves to user data", async () => {
const user = await fetchUser("123");
expect(user.name).toBe("George");
});await inside it is supported out of the box. The test passes if the promise resolves with the right value and fails if it rejects (or times out).
For testing that something throws (or rejects):
it("throws when given an invalid id", async () => {
await expect(fetchUser("")).rejects.toThrow("Invalid id");
});For testing time-dependent code, fake the clock:
import { vi } from "vitest";
it("debounces calls", async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced("a");
debounced("b");
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledWith("b");
vi.useRealTimers();
});Fake timers are essential for testing setTimeout, setInterval, debounce, throttle, retry-with-backoff logic — anything time-based.
Where unit tests live
Two common conventions:
Co-located (popular for modern JS projects):
src/
lib/
format.ts
format.test.ts
components/
Counter.tsx
Counter.test.tsx
Separated (popular in older / Java-influenced projects):
src/lib/format.ts
tests/lib/format.test.ts
Co-located is now the dominant convention in JS/TS — easier to find tests, easier to move code without orphaning tests, easier to delete code along with its tests.
The file extension convention: .test.ts or .spec.ts. Vitest and Jest both pick these up automatically.
How many unit tests is enough?
Two extremes to avoid:
- Too few — only the happy path; no edge cases; a refactor reveals bugs the tests should have caught
- Too many — testing every line, every conditional, every imaginable input; tests outnumber code; refactors break dozens of tests for no real reason
A useful middle: one test per behavior, not per code path. The shipping calculator from Why test? had 4 branches and 5 tests (one for each branch, plus a boundary case). That’s about right.
Code coverage is a starting metric. Aim for 70–90% on critical logic; less on glue code. But coverage is necessary, not sufficient — 100% coverage with no meaningful assertions is worthless.
Common gotchas
-
Tests that fail randomly are worse than no tests. Flakiness comes from: time dependencies (use fake timers), test order dependencies (each test should be independent), shared mutable state, race conditions in async code. Fix root causes; don’t
.skipthem. -
Tests should be independent. Running
it1 should not affectit2. Avoid shared module-level mutable state. Reset state inbeforeEach. -
Mocking too much hides bugs. A test that mocks 80% of the code under test and asserts “we called the mocks correctly” doesn’t test what the code DOES. It tests what the code’s interface looks like. Real behavior is in the parts you mocked away.
-
expect(x).toBe(y)is strict equality.toBe({...})always fails for objects (object identity, not value equality). UsetoEqualfor deep equality on objects. -
toBeTruthy()/toBeFalsy()are vague. Prefer specific assertions:toBe(true),toBe(null),toBe(undefined). The vague assertions accept many wrong values. -
Snapshot tests are seductive but rarely useful. They’re easy to write; their failures are easy to “fix” by accepting the diff. Use them sparingly, only for small intentional outputs.
-
Tests with
ifs in them probably should be multiple tests. A test that has branching logic is testing multiple things; split it. -
Test names should describe behavior, not mechanism.
it("calls the validator function")couples the test to implementation.it("rejects passwords shorter than 8 characters")describes behavior. -
beforeEachis your friend. Repeating setup across many tests is a smell. Hoist common setup. But beware: complexbeforeEachhierarchies make tests hard to follow. -
A skipped test (
it.skip) is a bug report. Skipped tests should be fixed or deleted. Don’t let them rot. -
Test files inflate dependency size. Don’t import the actual production bundle into your test if you only need a small piece. Especially relevant for component tests — render leaf components, not whole pages.
-
The component test doesn’t test the page. Rendering
<Counter />in isolation doesn’t test how it’s used elsewhere. Components passing unit tests but failing E2E is normal. -
Vitest watch mode is your friend.
npx vitestwatches files and re-runs only affected tests. Sub-second feedback loops change how you work. -
Pin your dependencies. A point release of Vitest can break some tests in subtle ways. Use a lockfile; review changelogs on upgrade.
-
toHaveBeenCalledWithdoes deep equality. Two objects that LOOK the same may differ byundefinedvs missing keys. Useexpect.objectContainingfor partial matches. -
Don’t test private internals. A test that calls a private function directly (via casting or hacks) is testing the implementation, not the contract. Test through the public interface.
-
React Testing Library’s queries fail loudly on purpose.
getByText("Submit")throws if not found (good — fail fast).queryByTextreturns null (good for “expect this to NOT be there”). Pick the right query per assertion. -
Console.errors in tests are warnings to investigate. React often logs errors that don’t fail tests. Configure Vitest to fail on unexpected console.errors so you don’t miss them.
-
Tests can leak into each other through module caches. A module that has top-level state (
let cache = ...) survives across tests in the same file. Usevi.resetModules()or isolate via the test runner’s isolation settings. -
Random data in tests = random failures. Don’t use
Math.random(),new Date(), orcrypto.randomUUID()directly in tests. Inject seeds or stub them. -
Component tests need a JSDOM environment. Default Vitest runs in Node, which has no DOM. Configure
environment: 'jsdom'(or'happy-dom'for speed) invitest.config.tsfor components. -
CI failure ≠local pass = environment drift. If a test passes locally and fails in CI (or vice versa), suspect: file system case sensitivity, timezone differences, locale, missing env vars, Node version mismatch.
-
Don’t measure success by test count. “Wrote 50 new tests” is meaningless if they all assert trivial things. “We have unit tests for every business-rule branch” is the goal.
-
AI-generated tests need review. AI assistants can write convincing-looking tests that hardcode wrong expected values, or that test the bug rather than the behavior. Read them as carefully as you’d read AI-generated code.
See also
- Why test? 🟩 — the foundational case
- Integration tests 🟩 — the middle tier
- End-to-end (E2E) tests 🟩 — the user-facing tier
- Linting 🟩 — the cheapest companion
- Type checking 🟥 — another cheap quality layer
- Code review 🟥
- CD 🟩 — where unit tests run automatically
- TypeScript 🟩 — typed code is easier to unit-test
- React 🟩 — what RTL renders in isolation
- Glossary: Test, Mock, Coverage, Jest
Sources
- Vitest docs — the modern default for JS/TS
- Testing Library docs — the canonical component testing approach
- Kent C. Dodds — Common mistakes with React Testing Library
- Vitest API reference —
vi.fn,vi.useFakeTimers, etc. - Jest docs — for older codebases still on Jest
- Martin Fowler — UnitTest — definitions and debates