Why test at all
Tests exist so you can change code without fear. A suite you trust turns “I hope I didn’t break anything” into “the suite is green, ship it.” That’s the entire value proposition — and it’s why a test suite full of tests that don’t actually verify anything is worse than useless: it grants confidence it hasn’t earned.
This guide uses Vitest, which has become the default for Vite-based projects: fast, ESM-native, and its API mirrors Jest closely enough that Jest knowledge transfers directly.
Setup
npm install -D vitest
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
vitest runs in watch mode (reruns on file change — great while developing). vitest run runs once and exits, which is what CI needs. That distinction matters: put vitest run in your pipeline, or CI hangs forever waiting in watch mode.
Your first test
// src/math.js
export function add(a, b) {
return a + b;
}
// src/math.test.js
import { describe, it, expect } from 'vitest';
import { add } from './math.js';
describe('add', () => {
it('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negatives', () => {
expect(add(-1, -1)).toBe(-2);
});
});
describe groups related tests; it (or test) is a single case; expect makes assertions. Run npm test and it watches for changes.
What makes a test worth writing
The number of tests is a vanity metric. What matters: would this test fail if the behavior broke? A test that can’t fail on a real regression is decoration.
// ❌ Passes no matter what add() does — worthless
it('runs without error', () => {
add(2, 3);
expect(true).toBe(true);
});
// ✅ Fails if add() ever returns the wrong value
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
Test behavior and contracts, not implementation details. If a test breaks every time you refactor internals without changing behavior, it’s testing the wrong thing and it’ll train the team to ignore failures.
Test the edges, not just the happy path
The happy path rarely breaks. Bugs live at the boundaries:
describe('divide', () => {
it('divides normally', () => {
expect(divide(10, 2)).toBe(5);
});
// The cases that actually catch bugs:
it('handles division by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
it('handles decimals', () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 2);
});
it('handles negative results', () => {
expect(divide(-10, 2)).toBe(-5);
});
});
Note toBeCloseTo for floating point — 0.1 + 0.2 === 0.3 is false in JavaScript, so never assert exact equality on computed floats. That’s not a Vitest quirk; it’s IEEE 754, and toBe on a float sum is a flaky test waiting to happen.
Checklist for edges: empty input, zero, negative, null/undefined, very large values, boundary conditions (off-by-one), and the error cases.
Arrange-Act-Assert
Structure each test in three clear phases:
it('applies a discount code', () => {
// Arrange — set up inputs and state
const cart = new Cart();
cart.add({ id: 1, price: 100 });
// Act — perform the one thing under test
cart.applyDiscount('SAVE20');
// Assert — verify the outcome
expect(cart.total).toBe(80);
});
One logical action per test. If you’re asserting five unrelated things, that’s probably five tests. When a focused test fails, its name alone tells you what broke — which is why good test names describe behavior (applies a discount code), not mechanics (test discount function).
Mocking: replace what you don’t control
Tests should be fast and deterministic. A test that hits a real API is slow, flaky, and fails when you’re offline. Mock the boundary:
import { describe, it, expect, vi } from 'vitest';
it('fetches and formats a user', async () => {
// Replace global fetch with a controlled fake
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Ada' }),
});
vi.stubGlobal('fetch', mockFetch);
const user = await getUser(1);
expect(user.name).toBe('Ada');
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
vi.fn() creates a spy you can inspect (was it called? with what?) and control (what does it return?). mockResolvedValue handles promises. You can also assert on how it was called, which catches “it returned the right thing but hit the wrong endpoint” bugs.
The mocking tradeoff, stated honestly: every mock is an assumption that the real thing behaves the way your mock does. Mock the wrong thing and you get a green suite over broken code — the mock says the API returns {name} but the real API changed to {fullName} and only production finds out. Mock external boundaries (network, time, filesystem, third-party SDKs); don’t mock the code you’re actually trying to test.
Mocking modules
import { vi } from 'vitest';
import { sendEmail } from './email.js';
vi.mock('./email.js', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
it('sends a welcome email on signup', async () => {
await signup({ email: 'ada@example.com' });
expect(sendEmail).toHaveBeenCalledWith(
'ada@example.com',
expect.stringContaining('Welcome')
);
});
expect.stringContaining and friends (expect.any(Number), expect.objectContaining({...})) let you assert the parts that matter without pinning down the parts that don’t — so the test doesn’t break every time you tweak the email’s wording.
Controlling time
Time-dependent code is a classic flaky-test source. Fake it:
import { vi, it, expect, beforeEach, afterEach } from 'vitest';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('expires a token after one hour', () => {
const token = createToken();
expect(token.isValid()).toBe(true);
vi.advanceTimersByTime(60 * 60 * 1000 + 1); // jump 1h forward
expect(token.isValid()).toBe(false);
});
No setTimeout waiting, no real clock, fully deterministic. vi.setSystemTime(new Date('2026-01-01')) pins “now” so tests that depend on the current date don’t break on New Year’s Day — a real bug that hits date-sensitive code every year.
Testing async code
it('resolves with data', async () => {
await expect(fetchData()).resolves.toEqual({ status: 'ok' });
});
it('rejects on network error', async () => {
await expect(fetchData()).rejects.toThrow('Network error');
});
The await is not optional. Forget it and the test passes before the assertion runs — a false green that hides real failures. If tests pass suspiciously fast on async code, a missing await is the first suspect.
setup and teardown
describe('UserRepository', () => {
let repo;
beforeEach(() => {
repo = new UserRepository(); // fresh instance per test
});
afterEach(() => {
repo.clear(); // clean up so tests don't leak into each other
});
it('saves a user', () => {
repo.save({ id: 1, name: 'Ada' });
expect(repo.count()).toBe(1);
});
it('starts empty', () => {
expect(repo.count()).toBe(0); // fails if previous test leaked
});
});
beforeEach giving each test fresh state is what makes tests independent — able to run in any order, in isolation. Shared mutable state between tests is the top cause of “passes alone, fails in the suite” mysteries, where test A leaves data that test B accidentally depends on.
Coverage: a floor, not a goal
npm install -D @vitest/coverage-v8
npm run test:coverage
Coverage tells you which lines ran during tests. It does not tell you those lines are correctly verified — you can execute a line without asserting anything about what it did. 100% coverage with weak assertions catches nothing.
Use coverage to find blind spots (“that entire error-handling branch is never exercised”), not as a target to chase. Chasing 100% produces tests written to touch lines rather than to verify behavior. 70–80% of meaningful code with strong assertions beats 100% of everything with expect(true).toBe(true).
What not to test
- Third-party libraries — trust that React renders and Postgres queries; test your usage, not their internals.
- Trivial getters/setters — no logic, no test needed.
- Generated code — test the generator once, not its output.
- Implementation trivia — private method call counts that break on every refactor.
Testing everything indiscriminately produces a brittle suite that punishes refactoring, which trains the team to disable or ignore it. Aim tests at the code where a bug would actually hurt.
A realistic suite structure
src/
cart/
cart.js
cart.test.js # unit: pure logic, fully mocked deps
api/
users.js
users.test.js # integration: real-ish, mock only network
e2e/
checkout.test.js # end-to-end: the critical money path
The testing pyramid holds: many fast unit tests, fewer integration tests, a handful of end-to-end tests over the flows that would cost you money or trust if they broke (login, checkout, signup). Invert it — mostly slow e2e tests — and your suite becomes too slow to run and too flaky to trust.
Wiring it into CI
- run: npm run test:run # 'run', not watch mode
Tests that don’t run on every PR will rot. Gate merges on a green suite (see branch protection) and the suite stays honest, because a broken test now blocks the merge instead of being quietly ignored.
The bottom line
Good tests are the thing that lets you move fast without breaking things — but only if they’d actually fail when something breaks. Test behavior at the boundaries, mock what you don’t own (and no more), keep tests independent and deterministic, and treat coverage as a flashlight rather than a scoreboard. A hundred honest tests you trust are worth more than a thousand you’ve learned to ignore.
