Vitest and the grain of testing
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.
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.
warragon case studies — rounds 6~9 (2026-05-04)
Concrete cases (commit hashes + file paths) showing how these principles play out in the monorepo.
vi.hoisted — server-action mock failure pattern
admin pryzeet/points/actions.test.ts broke after round 6 because the next/cache mock omitted revalidateTag:
// ❌ round 6 leftover (revalidateTag not mocked)
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
// ✅ round 7 fix (commit 87ad987a)
vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }));
A mock signature mismatched with the real module is not caught at build time — it breaks at the first runtime call. Use vi.hoisted and mirror the full export surface.
sed bulk migration — 51 files: sync → async
After round 8, da2ari-prod repeated JsonWebTokenError: invalid algorithm dozens of times. Root cause: Supabase Cloud issues access_tokens with ES256 (asymmetric EC), but jwt.verify(token, JWT_SECRET) only supports HS256.
Fix: migrate 134 call sites (51 files) of verifyJwt → await verifyJwtAsync (commit aa91c142):
sed -i 's/\bverifyJwt\b/verifyJwtAsync/g' "$f"
sed -i 's/\(payload\s*=\s*\)verifyJwtAsync(/\1await verifyJwtAsync(/g' "$f"
Run pnpm tsc --noEmit after sed. It catches the three files where await was inserted into a sync function (top-level await error + Promise vs JwtPayload mismatch). sed → tsc → fix → re-run is the standard SOP for bulk migration.
testcontainers compile-only gate vs full execution
The 30 new da2ari-api ControllerTests (round 8) would take ~5 minutes booted together (PG 17 + 21 supabase migrations). The PR-stage gate is compile-only (./gradlew :da2ari-api:compileTestJava); full execution defers to nightly CI.
// MockMvc smoke rule — passes on 200/401/4xx when seed data is absent
private void assertRouted(int s) {
assertTrue(s >= 200 && s < 600, "routing abnormal status=" + s);
}
Trades 200-payload validation for guaranteeing bean registration + routing + auth filter consistency. First gate after PROD deploy ensuring no endpoint is 5xx.
TS6133 unused import — Dockerfile.prod tsc gate
dmddksl idb.test.ts blocked PROD build with TS6133: 'getUnreadBackgroundMessages' is declared but its value is never read (commit 205a40e4). DEV vitest ignored the warning, but Dockerfile.prod runs tsc && vite build, escalating unused imports to ERROR.
Lesson: test files must also pass tsc --noEmit. Use ESLint unused-imports/no-unused-imports + tsconfig.json "noUnusedLocals": true. After rounds 6~9, all four workspaces pass cleanly.
Auth regression checks — DEV/PROD verification
# Next BFF JsonWebTokenError count — expect 0 after deploy
docker logs da2ari-prod 2>&1 | Select-String "JsonWebTokenError" | Measure-Object
# Spring unused security noise — expect 0
docker logs da2ari-api-prod 2>&1 | Select-String "inMemoryUserDetailsManager|generated security password" | Measure-Object
docker logs da2ari-api-prod 2>&1 | Select-String "JWKS init|JWT Verification Failed" | Measure-Object
All three should be Count: 0. Anything ≥1 means regression.
Next
- observability-minimal
- vitest-pytest-real-world
See Vitest official, Jest official, the Node test runner, Testing Library, Playwright, and Test Pyramid (Martin Fowler).