Testing with Vitest: From Zero to a Test Suite You Trust

A practical guide to testing JavaScript and TypeScript with Vitest — unit tests, mocking, async testing, coverage, and how to write tests that catch regressions instead of just inflating a number.

Developer Workflow: Testing with Vitest: From Zero to a Test Suite You Trust

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.