th aliases, environment variable prefixes, and setup execution order. Create vitest.config.ts at the project root:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/e2e/**'],
alias: {
'~': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@lib': path.resolve(__dirname, './src/lib'),
},
env: {
NODE_ENV: 'test',
...process.env,
},
},
});
Architecture Rationale:
alias mapping replaces Vite's automatic tsconfig.json path resolution. Next.js uses ~/* or custom aliases; Vitest requires explicit mapping.
env injection ensures process.env.NEXT_PUBLIC_* variables are available during test execution. Vite's import.meta.env is intentionally omitted to prevent cross-framework leakage.
exclude isolates unit tests from Playwright/Cypress E2E suites, preventing runner conflicts.
Layer 3: Framework Boundary Mocking
Next.js exports (next/navigation, next/image, next/headers) cannot execute in a Node environment. They must be mocked at the module boundary. Create test/setup.ts:
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
import { render as rtlRender } from '@testing-library/react';
// Framework navigation mocks
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => '/dashboard',
useSearchParams: () => new URLSearchParams('tab=settings'),
useServerInsertedHTML: vi.fn(),
}));
// Asset component mocks
vi.mock('next/image', () => ({
__esModule: true,
default: vi.fn(({ src, alt, ...props }) => {
return <img src={src} alt={alt} {...props} data-testid="next-image" />;
}),
}));
// Custom render wrapper for consistent provider injection
export function render(ui: React.ReactElement, options = {}) {
return rtlRender(ui, {
wrapper: ({ children }) => (
<div data-testid="test-root">{children}</div>
),
...options,
});
}
export * from '@testing-library/react';
export { render };
Architecture Rationale:
__esModule: true is mandatory for next/image because Next.js uses ES module default exports. Omitting it causes TypeError: next_image__WEBPACK_DEFAULT_EXPORT__ is not a function.
useSearchParams and useServerInsertedHTML are included because modern Next.js apps frequently destructure them. Incomplete mocks cause runtime crashes during component mount.
- The custom
render wrapper standardizes provider injection (theme, auth, context) across all tests, eliminating repetitive boilerplate.
Layer 4: Server vs Client Component Strategy
Vitest executes in a DOM-simulated Node environment. It cannot render React Server Components (RSC) that rely on server-only APIs (headers(), cookies(), database calls). The production strategy separates concerns:
- Client Components & Hooks: Test with Vitest. These run in the browser and align with
happy-dom execution.
- Server Components: Test logic extraction. Move data-fetching and server-side transformations into pure functions or custom hooks. Test those functions directly.
- Full Page Integration: Delegate to Playwright or Cypress. These tools execute against a running Next.js server, validating RSC hydration, routing, and edge behavior.
This separation prevents test flakiness and ensures unit tests remain fast and deterministic.
Pitfall Guide
1. The __esModule Default Export Trap
Explanation: Next.js components use ES module default exports. Vitest's vi.mock() returns a CommonJS-style object by default. Without __esModule: true, the mock fails to resolve the default export correctly.
Fix: Always include __esModule: true when mocking next/image, next/font, or any Next.js default export.
2. Environment Variable Prefix Mismatch
Explanation: Vite exposes variables via import.meta.env.VITE_*. Next.js uses process.env.NEXT_PUBLIC_*. Mixing prefixes causes undefined values during test execution.
Fix: Explicitly load .env.local using dotenv in vitest.config.ts and map variables to process.env in the env configuration block. Never rely on Vite's automatic prefix injection.
3. jsdom vs Server Component Rendering
Explanation: Attempting to render a Server Component in happy-dom or jsdom triggers runtime errors because server-only APIs (headers(), cookies(), draftMode()) are unavailable.
Fix: Restrict Vitest to 'use client' components and pure logic. Use Playwright for full-page RSC validation. Extract server logic into testable utility functions.
4. Path Alias Resolution Drift
Explanation: Vitest does not automatically read tsconfig.json or jsconfig.json path mappings. Missing aliases cause Cannot find module errors during import.
Fix: Explicitly define alias in vitest.config.ts using path.resolve(). Keep aliases synchronized with tsconfig.json using a shared configuration script or lint rule.
5. Mock Hoisting & Test Isolation Failure
Explanation: vi.mock() calls are hoisted to the top of the file by Vitest. If mocks are defined inside beforeEach or test blocks, they leak across tests or fail to apply.
Fix: Declare all vi.mock() calls at the module root. Use vi.clearAllMocks() in afterEach to reset call history without destroying mock implementations.
6. Over-Mocking Internal Dependencies
Explanation: Mocking components that should be rendered normally (e.g., UI primitives, context providers) creates false positives. Tests pass because mocks hide integration bugs.
Fix: Mock only framework boundaries (next/*, external APIs, browser APIs). Render internal components normally. Use vi.spyOn() for function interception instead of full module replacement.
7. Ignoring CSS Module & Asset Handling
Explanation: Next.js compiles CSS modules and static assets during build. Vitest encounters raw .module.css or .svg imports and throws syntax errors.
Fix: Add css: { modules: { classNameStrategy: 'non-scoped' } } to Vitest config. Use vite-plugin-svgr or mock static assets with ?url imports in setup files.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
Testing 'use client' components & hooks | Vitest + happy-dom | Fast execution, native ESM, instant HMR | Low (dev dependency only) |
| Validating Server Component rendering | Playwright / Cypress | Executes against real Next.js server, validates hydration | Medium (CI runner overhead) |
| Testing data-fetching logic | Pure function unit tests | Isolates business logic from framework boundaries | Low (no framework mocks needed) |
| Legacy CommonJS codebase migration | Jest + ts-jest | Easier CommonJS interop, established migration path | High (Babel config, slower DX) |
| Edge runtime compatibility testing | Vitest + @edge-runtime/vm | Simulates edge environment, validates fetch/headers | Medium (requires edge-specific mocks) |
Configuration Template
vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/e2e/**', '**/*.d.ts'],
alias: {
'~': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@lib': path.resolve(__dirname, './src/lib'),
'@hooks': path.resolve(__dirname, './src/hooks'),
},
env: {
NODE_ENV: 'test',
...process.env,
},
css: {
modules: {
classNameStrategy: 'non-scoped',
},
},
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/**/*.test.{ts,tsx}'],
},
},
});
test/setup.ts
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
import { render as rtlRender } from '@testing-library/react';
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => '/app',
useSearchParams: () => new URLSearchParams(),
useServerInsertedHTML: vi.fn(),
}));
vi.mock('next/image', () => ({
__esModule: true,
default: vi.fn(({ src, alt, ...props }) => (
<img src={src} alt={alt} {...props} data-testid="next-image" />
)),
}));
vi.mock('next/headers', () => ({
headers: () => new Headers(),
cookies: () => ({ get: vi.fn(), set: vi.fn() }),
}));
export function render(ui: React.ReactElement, options = {}) {
return rtlRender(ui, {
wrapper: ({ children }) => <div data-testid="test-root">{children}</div>,
...options,
});
}
export * from '@testing-library/react';
export { render };
Quick Start Guide
- Initialize the test runner: Run
npm install -D vitest happy-dom @testing-library/react @testing-library/jest-dom dotenv. Create vitest.config.ts and test/setup.ts using the templates above.
- Map your aliases: Replace
~, @components, and @lib in vitest.config.ts with your actual tsconfig.json path mappings. Ensure path.resolve() points to the correct source directories.
- Inject environment variables: Add
dotenv.config({ path: '.env.local' }) to the config. Verify process.env.NEXT_PUBLIC_* variables are accessible in tests by logging them in a temporary test file.
- Run the suite: Execute
npx vitest run to validate configuration. Fix any Cannot find module errors by checking alias paths or adding missing mocks to setup.ts.
- Isolate test boundaries: Move all
*.test.tsx files into a __tests__ or test directory. Configure exclude in vitest.config.ts to prevent E2E suites from conflicting with unit tests.
This configuration establishes a deterministic, high-performance testing layer that respects Next.js architecture while preserving Vitest's execution advantages. The boundary mocking strategy ensures framework dependencies never leak into unit tests, and the separation of client/server testing strategies prevents flaky assertions.