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.
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.
- Change the function and callers in a small scope.
- Run
tsc --noEmitto catch missingawaitand Promise type mismatches. - Propagate the async boundary to callers and run unit and integration tests.
- 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
Choosing test layers — Vitest, pytest, Cypress, and Playwright
Separate unit, integration, browser, and production smoke checks, then assign Vitest, pytest, Cypress, and Playwright without duplicating responsibilities.
HTTP API Mocking — WireMock · MockServer · Prism · MSW
Some external SaaS providers have no emulator: OAuth login · payments · dynamic DNS · Slack Webhook · AI providers, plus services where the official side never built one, such as Firebase Cloud Messaging. When you want…
LocalStack and MiniStack — Emulating AWS Locally
Most of AWS lives in magical data centers across the internet, charging money to do work. To verify on a developer machine, three costs apply daily: (1) creating a PoC account on real AWS, (2) paying somewhere, (3) stal…
Firebase Local Emulator Suite — Running a Firebase Bundle on a Laptop
Firebase started as a small startup in 2011 and was acquired by Google in 2014. Being a managed-first service, "no way to run it locally" was the right answer for a long time, but in March 2020 the picture changed when…