Skip to main content

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.

PatternNx TargetLayerWhat It Tests
*.schema.test.tstest-schemaL1 UnitZod schemas, DTOs, pure transforms
*.integration.spec.tstest-integrationL2 IntegrationServer actions, services, repos with real DB
*.browser.test.tsxtest-integration (browser)L2 BrowserClient 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:

  1. {name}.actions.ts — thin server action wrapper
  2. {name}.actions.schema.test.ts — Zod schema boundary tests
  3. {name}.actions.integration.spec.ts — service layer integration tests
  4. Updates project.json via updateProjectConfiguration to add test-schema and test-integration targets 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:

  1. Install: pnpm add -D vitest @nx/vite
  2. Add vitest.config.ts with setup file
  3. Replace jest.fn() with vi.fn(), jest.mock() with vi.mock()
  4. Replace jest.spyOn() with vi.spyOn()
  5. Remove ts-jest, @types/jest, Jest config files
  6. Update Nx targets from @nx/jest:jest to @nx/vite:test

The expect API is identical. Most tests migrate with find-and-replace.

Context

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?