Vitest
What if your test runner was fast enough to run on every save?
Migration status: The monorepo currently runs Jest. Vitest is the target state. This page documents where we're going — the patterns, file conventions, and Nx targets are designed for Vitest but the naming conventions work with both runners during transition. See Migration from Jest for the switchover path.
Vitest is the primary test runner. It replaces Jest for all new work. Same expect API, native ESM, native TypeScript — no transpilation step, no ts-jest configuration dance. Combined with Nx targets, it handles L1 unit tests, L2 integration tests, and L2 browser-mode tests from a single runner.
Nx Setup
Vitest runs through the @nx/vite:test executor. Each project gets up to three test targets based on file naming conventions.
// project.json
{
"targets": {
"test-schema": {
"executor": "@nx/vite:test",
"options": {
"include": ["{projectRoot}/src/**/*.schema.{test,spec}.ts"]
}
},
"test-integration": {
"executor": "@nx/vite:test",
"dependsOn": ["test-schema"],
"options": {
"include": ["{projectRoot}/src/**/*.integration.{test,spec}.ts"],
"environment": "node"
}
}
}
}
Global cascade in nx.json:
{
"targetDefaults": {
"test-integration": {
"dependsOn": ["test-schema"]
},
"e2e": {
"dependsOn": ["test-integration"]
}
}
}
Schema tests pass before integration tests run. Integration tests pass before E2E runs. Nx enforces the order.
Setup File
Next.js internals need mocking. This setup file eliminates the vi.hoisted() pain from every test file.
// vitest.setup.ts
import { vi } from 'vitest';
// Mock next/navigation
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
notFound: vi.fn(),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
usePathname: vi.fn(() => '/'),
useSearchParams: vi.fn(() => new URLSearchParams()),
}));
// Mock next/cache
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: vi.fn((fn) => fn),
}));
// Mock next/headers
vi.mock('next/headers', () => ({
cookies: vi.fn(() => ({
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
})),
headers: vi.fn(() => new Map()),
}));
Wire it in vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
environment: 'node',
},
});
File Naming
The naming convention routes tests to Nx targets. Colocate tests beside their source files.
| Pattern | Nx Target | Layer | What It Tests |
|---|---|---|---|
*.schema.test.ts | test-schema | L1 Unit | Zod schemas, DTOs, pure transforms |
*.integration.spec.ts | test-integration | L2 Integration | Server actions, services, repos with real DB |
*.browser.test.tsx | test-integration (browser) | L2 Browser | Client components with real DOM |
lib/services/
├── deal.service.ts
├── deal.service.schema.test.ts ← L1: Zod validation
├── deal.service.integration.spec.ts ← L2: real DB queries
└── deal.actions.ts
L1 Unit: Schema Tests
The highest-value unit test. Zod schemas define both the type and the runtime validator. Test the boundaries.
// deal.service.schema.test.ts
import { describe, it, expect } from 'vitest';
import { CreateDealSchema } from './deal.schemas';
describe('CreateDealSchema', () => {
it('accepts valid input', () => {
const result = CreateDealSchema.safeParse({
name: 'Acme Corp',
value: 50000,
stage: 'qualification',
});
expect(result.success).toBe(true);
});
it('rejects negative value', () => {
const result = CreateDealSchema.safeParse({
name: 'Acme Corp',
value: -1,
stage: 'qualification',
});
expect(result.success).toBe(false);
});
it('rejects unknown stage', () => {
const result = CreateDealSchema.safeParse({
name: 'Acme Corp',
value: 50000,
stage: 'invented-stage',
});
expect(result.success).toBe(false);
});
});
L1 Unit: Service Tests
Services extracted via the Thin Action / Fat Service pattern are pure functions. No Next.js mocking needed.
// deal.service.schema.test.ts (service logic tests)
import { describe, it, expect } from 'vitest';
import { calculateDealScore } from './deal.service';
describe('calculateDealScore', () => {
it('weights recent activity higher', () => {
const score = calculateDealScore({
value: 50000,
daysSinceContact: 3,
meetingsCount: 5,
});
expect(score).toBeGreaterThan(70);
});
});
L2 Integration: Server Actions
Test server actions against a real database. The "use server" directive is a bundler instruction — in the test runner, it's a plain async function.
// deal.actions.integration.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createDeal, getDeal } from './deal.actions';
import { db } from '@/lib/db';
import { deals } from '@/lib/db/schema';
describe('deal actions', () => {
beforeEach(async () => {
await db.delete(deals); // Clean slate
});
it('creates and retrieves a deal', async () => {
const created = await createDeal({
name: 'Acme Corp',
value: 50000,
stage: 'qualification',
});
expect(created.id).toBeDefined();
const retrieved = await getDeal(created.id);
expect(retrieved.name).toBe('Acme Corp');
expect(retrieved.value).toBe(50000);
});
it('rejects invalid input with structured error', async () => {
const result = await createDeal({
name: '',
value: -1,
stage: 'invalid',
});
expect(result.error).toBeDefined();
expect(result.error.fieldErrors).toHaveProperty('name');
});
});
L2 Integration: API Routes (NTARH)
next-test-api-route-handler (NTARH) runs API routes with real Next.js routing. Test auth scenarios without a browser.
// api/deals/route.integration.spec.ts
import { describe, it, expect } from 'vitest';
import { testApiHandler } from 'next-test-api-route-handler';
import * as appHandler from './route';
describe('GET /api/deals', () => {
it('returns 200 with deals for authenticated user', async () => {
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const res = await fetch({
method: 'GET',
headers: { Authorization: 'Bearer valid-token' },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(Array.isArray(data.deals)).toBe(true);
},
});
});
it('returns 401 without auth', async () => {
await testApiHandler({
appHandler,
test: async ({ fetch }) => {
const res = await fetch({ method: 'GET' });
expect(res.status).toBe(401);
},
});
});
it('returns 422 for invalid query params', async () => {
await testApiHandler({
appHandler,
url: '/api/deals?limit=abc',
test: async ({ fetch }) => {
const res = await fetch({
method: 'GET',
headers: { Authorization: 'Bearer valid-token' },
});
expect(res.status).toBe(422);
},
});
});
});
L2 Browser Mode
Vitest Browser Mode (stable since v4) runs component tests in real Chromium. No JSDOM approximation. Combined with MSW, it tests client components with intercepted network requests.
// deal-card.browser.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DealCard } from './deal-card';
describe('DealCard', () => {
it('renders deal name and value', () => {
render(<DealCard deal={{ name: 'Acme', value: 50000, stage: 'won' }} />);
expect(screen.getByText('Acme')).toBeDefined();
expect(screen.getByText('$50,000')).toBeDefined();
});
it('shows stage badge with correct color', () => {
render(<DealCard deal={{ name: 'Acme', value: 50000, stage: 'lost' }} />);
const badge = screen.getByText('lost');
expect(badge.className).toContain('destructive');
});
});
MSW: Shared Mocking
Mock Service Worker provides a shared mocking language across all non-E2E layers. Define handlers once, use in L1, L2, and browser tests.
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/deals', () => {
return HttpResponse.json({
deals: [
{ id: '1', name: 'Acme Corp', value: 50000, stage: 'qualification' },
],
});
}),
http.post('/api/deals', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
}),
];
For RSC server-side fetches, wire MSW into instrumentation.ts:
// instrumentation.ts (test environment only)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NODE_ENV === 'test') {
const { server } = await import('./mocks/server');
server.listen({ onUnhandledRequest: 'error' });
}
}
Nx Generator
The Nx generator scaffolds the correct test structure for new server actions. It uses @nx/devkit patterns.
The generator emits:
{name}.actions.ts— thin server action wrapper{name}.actions.schema.test.ts— Zod schema boundary tests{name}.actions.integration.spec.ts— service layer integration tests- Updates
project.jsonviaupdateProjectConfigurationto addtest-schemaandtest-integrationtargets if missing
Uses generateFiles for templates, updateProjectConfiguration for target wiring. The naming convention ensures the generated files route to the correct Nx target automatically.
Migration from Jest
For existing Jest projects:
- Install:
pnpm add -D vitest @nx/vite - Add
vitest.config.tswith setup file - Replace
jest.fn()withvi.fn(),jest.mock()withvi.mock() - Replace
jest.spyOn()withvi.spyOn() - Remove
ts-jest,@types/jest, Jest config files - Update Nx targets from
@nx/jest:jestto@nx/vite:test
The expect API is identical. Most tests migrate with find-and-replace.
Context
- Testing Platform — Trophy strategy, economics, Story Contract connection
- Testing Strategy — Layer model, selection rules, hexagonal advantage
- Testing Stack — All testing tools overview
- Type-First Development — Types as L0 verification
Links
- Vitest Documentation — Official docs
- Vitest Browser Mode — Component testing in real browsers
- MSW — Mock Service Worker
- NTARH — Next.js API route testing
- Kent C. Dodds — Testing Trophy — The original trophy model
Questions
If Vitest and Jest share the same expect API, what's the actual cost of NOT migrating?
- When
vi.mock('next/cache')hides a real caching bug, is the test giving confidence or false security? - At what point does Vitest Browser Mode replace Playwright for component-level interaction testing?
- If the Nx generator emits tests that pass on day one but rot by month three, is the generator helping or hiding debt?