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.

Writing Better Tests with Vitest

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 import describe, it, and expect from 'vitest'. Use vi.fn() to create standalone mock functions, vi.spyOn() to wrap existing methods, and vi.mock() at the module level to replace whole imports. Run vitest --coverage after installing @vitest/coverage-v8 to see which branches your tests actually reach.

Series: Part 2 of 4.

On this page

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

  1. Create a small utility function in src/utils.ts that adds two numbers.
  2. Create src/utils.test.ts that imports and calls it with a known input.
  3. 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.ts or .spec.ts in the filename
  • Each it block tests one thing — one scenario, not a list of unrelated assertions
  • The three phases are in order: setup, then the call, then the check
  • describe names the function or module; it names 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 grid of circular test outcome indicators arranged in rows and columns, most filled with dark ink representing passing tests, a few outlined representing pending cases, and a few accent-coloured ones highlighted.

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.

Sources