Skip to main content

Vitest and the grain of testing

Vitest is a testing framework that grew out of the Vite ecosystem. It keeps a similar grain to Jest while handling ESM, TypeScript, and watch mode more lightly.

6 viewsAbout 7 min read
Table of contents

Vitest is a testing framework that grew out of the Vite ecosystem. It keeps a similar grain to Jest while handling ESM, TypeScript, and watch mode more lightly.

1. About Vitest

The Vite team (Anthony Fu and others) started it in 2021, and 1.0 shipped in 2023. It has been moving fast ever since. The site is vitest.dev, licensed under MIT.

Notable traits:

  • Runs on top of Vite's fast ESM/HMR infrastructure.
  • TypeScript works without extra setup.
  • An API close to Jest's (describe · it · test · expect).
  • Fast feedback in watch mode.
  • Multiple modules tested concurrently in the same process.
  • Extras like browser mode and in-source testing.

2. Compared with Jest

Jest was released in 2014 by Christopher Pojer and Meta. For a stretch it was the de facto standard in the React world. Coming from CommonJS roots, it needs extra setup for ESM and shows friction when paired with Vite. That is why much of the Vite-based frontend world has been moving to Vitest.

Most assertions and matchers (toBe · toEqual · toHaveBeenCalled, etc.) work identically in both frameworks.

3. Concurrency and isolation

Vitest spins up workers (threads or processes) and distributes test files across them. File-level isolation is the default.

[main] vitest CLI
  ├── worker-1 (a.test.ts)
  ├── worker-2 (b.test.ts)
  └── worker-3 (c.test.ts)

describe and test blocks within a single file run sequentially in the same worker, but parallelism is available via options. Weakening isolation can cause cross-test interference.

4. Watch mode and in-source testing

Running vitest or vitest --watch re-runs only the changed file plus the tests that depend on it. That keeps feedback instant even on large codebases.

There is also an in-source testing option, where small tests live inside source files inside if (import.meta.vitest) blocks. It fits putting usage examples right next to a small utility function. As files grow, moving tests out into a dedicated file is the common shape.

5. Mocking and coverage

vi.fn() · vi.spyOn() · vi.mock() mirror Jest's shapes. ESM mocking has its quirks compared to CommonJS (auto hoisting and friends). Follow the per-tool guides. Async helpers like expect.poll and expect.assertions(n) are available too.

Coverage offers two backends: v8 and istanbul. v8 is fast; istanbul produces richer reports. Threshold settings (thresholds) help catch regressions.

6. Other contenders

Tool Grain
Jest Meta · 2014. CommonJS first.
Vitest Vite team · 2021~. ESM and TypeScript friendly.
Mocha OpenJS · 2011. Usually paired with chai for matchers.
AVA 2015. Parallel-first.
Node built-in node:test Standard library since 2022. Lightweight.
Bun · Deno built-ins Each ships its own test runner.

For E2E and integration we have Playwright (Microsoft, 2020), Cypress (2017), Testing Library (Kent C. Dodds and others, 2018), and Storybook + test runner. Vitest fits unit and integration tests; Playwright fits browser E2E.

7. Quick start

import { describe, it, expect } from "vitest";
import { sum } from "./sum";

describe("sum", () => {
  it("adds two numbers", () => {
    expect(sum(1, 2)).toBe(3);
  });
});

The config file (vitest.config.ts) integrates with the Vite config. There is almost nothing to set up around builds or transpilation.

React and Vue components pair well with @testing-library/{react,vue}.

import { render, screen } from "@testing-library/react";
import Counter from "./Counter";

it("increments by 1 when the button is clicked", async () => {
  render(<Counter />);
  await userEvent.click(screen.getByRole("button"));
  expect(screen.getByText("1")).toBeInTheDocument();
});

8. Unit vs integration vs E2E

  • Unit — small functions and logic with no external dependencies.
  • Integration — verify behavior with multiple modules combined. Use Testcontainers or in-memory when a DB is required.
  • E2E — browsers and real environments. Separate tooling.

Mixing all three in one folder lengthens feedback time. Split them via directory or filename conventions.

9. The cost of tests

Tests are not strictly better the more we add. The following costs grow with them.

  • Maintenance — every code change drags the test along. Poorly written tests obstruct change.
  • Run time — more integration and E2E tests stretch CI.
  • False signal — tests that disagree with intent muddle regression alarms.
  • Coupling — tests bound to implementation details block refactors.

10. Tests as specification

Test code is an executable spec for "this function should behave this way." Name and structure them so they read as specs. The test name comes before the matcher.

it("returns 0 for an empty array", () => {});
it("ignores negative numbers when present", () => {});

Behavior tests verify externally observable behavior. They survive refactors well. Structure tests target internal functions and methods. They are precise but tightly coupled.

The usual shape is to lean on behavior tests and keep structure tests for risky or complex functions only.

11. Where to put more

Higher-value spots:

  • Irreversible territory like billing, charging, auth, and data loss.
  • Core domain logic (price calculation, permissions, state machines).
  • Places where regressions kept happening.

Many find boundary cases in domain logic more valuable than pixel-perfect checks on lightweight presentation components.

12. Common stumbles

Eye-catching 100% coverage — line coverage isn't the same as meaningful regression protection.

Jest → Vitest migration — APIs are similar, but ESM mocking, timers, and environment differences break some tests.

Test environment differences — jsdom · happy-dom · browser mode each behave differently. Some DOM APIs may be missing.

Time and randomness — direct use of Date.now or Math.random makes tests flaky. Use vi.useFakeTimers() or dependency injection.

Shared state — module-top-level objects bleed across tests. Add isolation or a beforeEach reset.

Order dependence — when one test sets up another. Tests must pass in random order too.

Over-mocking — mock every dependency and we end up testing the consistency of the mocks. Prefer real dependencies (when possible) or integration tests.

Closing thoughts

Tests cost more during code changes than during code creation. Tests that withstand change are written at the interface level; weak tests are bound to implementation details. Writing them as specifications is the habit that makes the biggest difference.

How to record practical cases

Abstract principles should be recorded as reproducible failure conditions rather than proper names from one repository. These cases apply to any web, API, or worker combination.

The export surface of a module mock

When the real module gains a function but a test mock does not expose it, type checking may pass while the first call fails.

// ❌ only part of the real export surface
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));

// ✅ expose the functions the caller uses
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));

Use vi.hoisted and a mock-contract test to keep the real public surface visible.

Bulk migration from sync to async

Changing authentication verification to an async provider is not a search-and-replace exercise.

  1. Change the function and callers in a small scope.
  2. Run tsc --noEmit to catch missing await and Promise type mismatches.
  3. Propagate the async boundary to callers and run unit and integration tests.
  4. Compare error codes and latency before and after.

The compiler is the first regression detector; a real provider and a read-only smoke are the final evidence.

Layer integration-test cost

Container-heavy integration tests can make pull-request feedback slow. Keep compile, routing, auth-filter, and 5xx checks in the fast gate, and run the full container suite nightly or in an explicit environment.

private void assertRouted(int status) {
    assertTrue(status >= 200 && status < 600, "routing abnormal status=" + status);
}

This does not replace payload validation; add response-contract checks for read-only endpoints at the next layer.

Test files are production-build inputs

A development test runner may tolerate an unused import that production tsc rejects. Run noUnusedLocals, lint, tsc --noEmit, and the production build in the same change, recording the failed command and rerun path.

Next

  • observability-minimal
  • vitest-pytest-infra

See Vitest official, Jest official, the Node test runner, Testing Library, Playwright, and Test Pyramid (Martin Fowler).

More in quality

All in this category →

Related posts

Was this article helpful?