Configuring Vitest for Next.js App Router
Root Cause Analysis: Why Default Vitest Fails with Next.js App Router
Next.js App Router enforces strict server/client component boundaries and mandates ESM-only module resolution. Default Vitest configurations assume a unified Node.js or browser environment, which immediately breaks when the runner encounters next/navigation, next/dynamic, or next/font. The core conflict originates from Vite’s dependency pre-bundling mechanism, which bypasses Next.js internal transforms. This bypass triggers SyntaxError: Cannot use import statement outside a module and causes hydration mismatches during test initialization. Proper alignment requires explicit environment isolation and dependency inlining, as documented in foundational Vitest Configuration & Setup guidelines. Without explicit configuration, Vite attempts to bundle Next.js internals as CommonJS, violating the App Router’s strict module graph and causing cascading runtime exceptions.
Reproducible Setup & Exact Configuration Patterns
Establish a deterministic testing baseline by implementing a strict configuration matrix. The following patterns guarantee consistent execution across local environments and CI runners by eliminating window is not defined and ReferenceError: document is not defined during component mounting. Aligning these patterns with established Component & Integration Testing Frameworks ensures cross-project consistency.
vitest.config.ts must explicitly target jsdom, inline Next.js internals to prevent pre-bundling collisions, and resolve project aliases:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setup.ts'],
deps: {
inline: ['next'],
},
alias: {
'@/': new URL('./src/', import.meta.url).pathname,
},
},
});
src/setup.ts registers DOM matchers, polyfills missing browser APIs, and establishes global mock hoists:
// src/setup.ts
import '@testing-library/jest-dom';
import { vi } from 'vitest';
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
__mocks__/next/navigation.ts provides a strict, type-safe stub for routing primitives. Place this file at the project root’s __mocks__/next/navigation.ts so Vitest resolves it via its module aliasing:
// __mocks__/next/navigation.ts
import { vi } from 'vitest';
export const useRouter = vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: '/',
}));
export const usePathname = vi.fn(() => '/');
export const useSearchParams = vi.fn(() => new URLSearchParams());
In test files, hoist the mock before any imports that consume it:
// MyComponent.test.tsx
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), pathname: '/' })),
usePathname: vi.fn(() => '/'),
useSearchParams: vi.fn(() => new URLSearchParams()),
}));
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';
Exclude next/font and next/image from Vite transforms by adding them to deps.inline or excluding them via server.deps.external:
// vitest.config.ts addition for next/font and next/image
test: {
deps: {
inline: ['next'],
// If 'next' alone isn't enough, list sub-paths explicitly:
// inline: [/^next\//],
},
}
Pipeline Stability & Fast Resolution Strategies
Achieve pipeline stability by enforcing deterministic execution paths and eliminating race conditions. Configure Vitest to persist cache directories, apply strict timeouts for async data fetching, and isolate flaky hydration tests.
CI execution must enforce cache persistence, verbose reporting, and strict timeout boundaries:
npx vitest run --reporter=verbose --testTimeout=5000
Flaky hydration tests are mitigated by wrapping render calls in explicit DOM mutation assertions. Avoid relying on implicit React reconciliation cycles; instead, enforce deterministic waits:
import { render, screen, waitFor } from '@testing-library/react';
import { act } from 'react';
test('renders async data without hydration mismatch', async () => {
await act(async () => {
render(<AsyncComponent />);
});
await waitFor(
() => expect(screen.getByRole('heading')).toBeInTheDocument(),
{ timeout: 2000 }
);
});
Implement snapshot versioning with --updateSnapshot gated by mandatory PR review to prevent silent regression drift. Enforce test.sequence.concurrent: false only for stateful integration suites that cannot run in parallel.
Validation Matrix & Troubleshooting Protocol
Deploy a decision matrix correlating runtime errors to exact configuration patches for rapid incident resolution. Reference the Vitest Configuration & Setup documentation for baseline environment constraints.
| Error Signature | Root Cause | Exact Mitigation |
|---|---|---|
TypeError: Cannot read properties of undefined (reading 'pathname') |
Mock not applied before import | Ensure vi.mock('next/navigation', ...) appears at the top of the test file before any imports. |
SyntaxError: Unexpected token 'export' |
Vite pre-bundling Next.js CJS/ESM hybrids | Add the offending package to deps.inline — e.g., inline: [/^next\//]. |
Warning: Text content did not match. Server: "..." Client: "..." |
Non-deterministic hydration or missing use client directive |
Wrap client-only components with suppressHydrationWarning or ensure setup.ts polyfills match SSR output exactly. |
Apply server.deps.external to bypass problematic compiled directories at the module resolution layer:
// vitest.config.ts addition
test: {
server: {
deps: {
external: [/node_modules\/next\/dist\/compiled\//],
},
},
}
Enforce strict mock hoisting for Next.js link components to prevent ESM resolution failures:
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
Define rollback procedures for breaking Next.js minor upgrades that invalidate existing Vite transforms. Maintain a version-locked vitest.config.ts and run a baseline suite before upgrading next or vite packages. Target performance benchmarks: under 50 ms per component test, under 2 s for integration suites, and zero unhandled promise rejections in CI logs.