Writing Better Tests with Vitest
I walk through Vitest setup, the AAA pattern, vi.fn() mocks, module mocking, snapshot testing, and coverage — with the common mistakes that make tests brittle.

A price calculation function was returning undefined for users who had no previous orders. The page still displayed “$0.00” because the template treated undefined as falsy — so every manual check passed. A Vitest test caught it three days before the feature shipped, when a new assertion on the return value failed immediately.
That kind of catch is what makes writing tests worth the setup cost. The test wasn’t sophisticated: arrange some input, call the function, assert the return value. Fifteen lines, no ceremony. But the bug would have reached production without it.
This post covers the mechanics — install and configure Vitest, structure tests with describe/it/expect, mock dependencies with vi.fn() and vi.spyOn(), replace whole modules, capture snapshots, and read coverage numbers that actually tell you something.
Quick answer: Install with
npm install -D vitest, add"test": "vitest"to your scripts, and importdescribe,it, andexpectfrom'vitest'. Usevi.fn()to create standalone mock functions,vi.spyOn()to wrap existing methods, andvi.mock()at the module level to replace whole imports. Runvitest --coverageafter installing@vitest/coverage-v8to see which branches your tests actually reach.
Series: Part 2 of 4.
On this page
- Install Vitest and write your first test
- describe, it, and the AAA pattern
- Controlling dependencies with vi.fn() and vi.spyOn()
- Module mocking and the hoisting trap
- Snapshot testing: when it helps and when it drifts
- Coverage and watch mode
- Tests will push back on your code
Install Vitest and write your first test
Vitest requires Vite 6.4 or higher and Node 22.12 or higher. If you have an existing Vite project, you likely meet both requirements already.
npm install -D vitest
Add the scripts to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}
vitest runs in watch mode by default, re-running tests affected by file changes. vitest run exits after one pass — that’s the one for CI.
For TypeScript projects, add a minimal config in vitest.config.ts or inside your existing vite.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
Vitest discovers test files by looking for the .test. or .spec. infix anywhere in the filename — src/pricing.test.ts works, src/tests/pricing.ts doesn’t. Once you have a test file, run npm test and the output will show each passing or failing case with timing.
Try this
- Create a small utility function in
src/utils.tsthat adds two numbers.- Create
src/utils.test.tsthat imports and calls it with a known input.- Run
npm test.Expected result: Vitest finds the file, runs the test, and prints a pass with a time. The process stays open in watch mode, waiting for changes.
describe, it, and the AAA pattern
describe groups related tests. it (or test — they’re identical) defines one test case. expect checks the result.
The most reliable structure for a test case is Arrange / Act / Assert — sometimes abbreviated AAA. Each phase has one job:
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing';
describe('calculateDiscount', () => {
it('returns 10% off for orders above 100', () => {
// Arrange
const orderTotal = 150;
// Act
const discounted = calculateDiscount(orderTotal);
// Assert
expect(discounted).toBe(135);
});
it('returns the original amount for orders at or below 100', () => {
const orderTotal = 80;
const result = calculateDiscount(orderTotal);
expect(result).toBe(80);
});
});
The separation matters more than it looks. When a test fails, the phase tells you what broke: if Arrange fails, your test data is wrong; if Act throws unexpectedly, there’s an uncaught error path; if Assert fails, the logic is wrong. That diagnostic clarity is why the pattern holds up.
For straightforward cases, the phases don’t need comment labels — the code shape says it. For anything with non-obvious setup, label them. A reader who lands on a failing test at 11pm will thank you.
Check this before moving on
- Your test files use
.test.tsor.spec.tsin the filename - Each
itblock tests one thing — one scenario, not a list of unrelated assertions - The three phases are in order: setup, then the call, then the check
-
describenames the function or module;itnames the specific case
Controlling dependencies with vi.fn() and vi.spyOn()
Real functions usually depend on other functions — they read from a database, send emails, call an API. You don’t want unit tests to run those. You want tests to be fast and not require a running server.
vi.fn() creates a mock function. It records every call and lets you control what it returns:
import { describe, it, expect, vi } from 'vitest';
import { createUser } from './users';
describe('createUser', () => {
it('calls the email service with the new user email', async () => {
// Arrange
const mockSend = vi.fn().mockResolvedValue({ delivered: true });
const email = 'user@example.com';
// Act
await createUser(email, mockSend);
// Assert
expect(mockSend).toHaveBeenCalledOnce();
expect(mockSend).toHaveBeenCalledWith(email);
});
});
vi.spyOn() is different. It wraps an existing method on an object rather than replacing it outright — the original implementation still runs unless you override it:
import { describe, it, expect, vi, afterEach } from 'vitest';
import * as logger from './logger';
import { processOrder } from './orders';
describe('processOrder', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('logs a warning for zero-quantity items', () => {
const spy = vi.spyOn(logger, 'warn');
processOrder({ itemId: 'abc-123', quantity: 0 });
expect(spy).toHaveBeenCalledWith(expect.stringContaining('zero-quantity'));
});
});
The afterEach(() => vi.restoreAllMocks()) block is not optional. Without it, spy state from one test bleeds into the next. An assertion that passes in test 1 can break test 3 because the call count adds up across tests. The error message will point at test 3 and you’ll spend time looking in the wrong place.
Module mocking and the hoisting trap
vi.mock() replaces an entire module’s exports with controlled versions. It’s useful when you can’t pass the dependency in as a parameter:
import { describe, it, expect, vi } from 'vitest';
import { getUser } from './users';
vi.mock('./database', () => ({
query: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
}));
describe('getUser', () => {
it('returns the first matching user from the database', async () => {
const user = await getUser(1);
expect(user.name).toBe('Alice');
});
});
Here’s the trap almost everyone runs into: vi.mock() calls are hoisted to the top of the file at compile time, before any other code runs. That means variables you declare outside the factory aren’t available inside it when you expect them to be.
// This does NOT work — mockQuery is undefined when vi.mock() runs
const mockQuery = vi.fn();
vi.mock('./database', () => ({
query: mockQuery, // undefined at hoist time
}));
The fix: define the mock function inside the factory. If you need to access it later for assertions, use vi.mocked() on the imported function after the mock is declared:
import { query } from './database';
vi.mock('./database', () => ({
query: vi.fn(),
}));
it('queries the database once', async () => {
vi.mocked(query).mockResolvedValue([{ id: 1, name: 'Alice' }]);
await getUser(1);
expect(vi.mocked(query)).toHaveBeenCalledOnce();
});
This is the pattern the Vitest mocking docs use, and it sidesteps the hoisting problem entirely.
Snapshot testing: when it helps and when it drifts
toMatchSnapshot() serializes a value and stores it in a .snap file. On the next run, it compares the current output to the stored version and fails if they differ.
import { it, expect } from 'vitest';
import { formatInvoice } from './invoice';
it('formats an invoice correctly', () => {
const output = formatInvoice({ id: 'INV-001', total: 99.99, currency: 'USD' });
expect(output).toMatchSnapshot();
});
Snapshot tests are useful for complex objects, formatted strings, or serialized output where spelling out every field in separate assertions would be verbose and brittle. The first run creates the snapshot; every subsequent run checks against it.
The failure mode is when nobody reads the snapshot diff when updating. You run vitest -u to update after a change, the .snap file changes, and the diff goes into the PR unread. Now the snapshot reflects a bug. The test is still “green” — it just accepted the wrong output as correct.
Treat .snap files as code. Read the diff during review just as you’d read a changed source file. For smaller values, toMatchInlineSnapshot() puts the snapshot directly in the test body, which makes it harder to skip:
it('returns the correct status message', () => {
expect(getStatus('inactive')).toMatchInlineSnapshot('"Account is inactive"');
});
Coverage and watch mode
Install the coverage provider before running coverage reports:
npm install -D @vitest/coverage-v8
Then:
npx vitest run --coverage
Vitest uses V8 coverage by default. Since version 3.2.0, it uses AST-based remapping that produces the same accuracy as Istanbul — so you’re not trading correctness for speed.
The number to pay attention to is branch coverage — the percentage of if/else paths, ternary conditions, and optional chains your tests actually execute. Statement coverage of 90% can still leave critical error paths unreachable if every test follows the happy path.
Configure thresholds in vitest.config.ts to enforce the target in CI:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
thresholds: {
branches: 80,
functions: 80,
},
},
},
});
A threshold makes the CI build fail if coverage drops. Without it, the number is informational and easy to ignore in a review.
In watch mode, the interactive terminal gives you a few useful keys: a re-runs all tests, u updates failing snapshots, q exits. Most developers leave the watch process open in a terminal pane alongside their editor — the feedback loop is fast enough that it’s not disruptive.

A test suite that mostly passes — with a few pending cases that branch coverage helps you find.
Tests will push back on your code
The test for that price calculation function wasn’t the hard part. The hard part was that the function mixed three responsibilities: reading from a data object, computing the discount, and formatting the result as a string. Testing each branch meant constructing a different data object every time, and the setup kept growing.
Splitting it into three smaller, focused functions made each one trivial to test. The tests didn’t reveal a testing problem — they revealed a design problem the code had been hiding.
That’s the shift worth expecting: writing tests will surface buried dependencies, missing return values, and boundaries drawn in the wrong place. A function that’s awkward to test is usually awkward to maintain for the same reason. The test is the first external consumer of your code. If the test struggles to use it, so will the next developer.
The practical step: when test setup starts feeling complicated, stop and ask whether the function itself is too complicated. The test is giving you a clear signal.