Vitest + Next.js 16 Testing Setup: From Zero to 27 Passing Tests (2026)
TL;DR - Quick Value
Problem: Setting up testing in Next.js 16 with ESM modules causes CommonJS compatibility errors with Jest.
Solution: Use Vitest + happy-dom for native ESM support, 2-3x faster tests, and zero-config TypeScript.
Result: 27 passing tests, 100% reliability, ~930ms execution time.
Quick Start (copy-paste ready):
# Install dependencies
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event happy-dom
# Run tests
npm test
Why Modern Applications Need Automated Testing
In today's fast-paced development environment, automated testing isn't optionalβit's essential. After building a comprehensive portfolio website with multiple interactive tools (equity calculators, salary comparisons, vacancy analyzers), I realized that manual testing was becoming a bottleneck. Every code change required testing dozens of user flows across multiple components.
This article chronicles my journey implementing a production-grade testing infrastructure using Vitest and React Testing Library in a Next.js 16 application. I'll share the technical decisions, challenges faced, and solutions that resulted in 27 passing tests with 100% reliability.
Why Vitest Over Jest?
The Technical Comparison
When evaluating testing frameworks for this Next.js 16 project, I compared the two leading options:
| Feature | Vitest | Jest |
|---|---|---|
| Setup Complexity | Minimal (Vite-native) | Requires transforms |
| Speed | 2-3x faster | Baseline |
| ESM Support | Native | Requires configuration |
| TypeScript | Zero-config | Needs ts-jest |
| Watch Mode | Instant | Slower rebuilds |
| UI Mode | Built-in | Requires separate packages |
The Decision
I chose Vitest for three critical reasons:
- Native ESM Support: Next.js 16 uses ES modules extensively. Vitest handles this without additional configuration.
- Developer Experience: Instant hot-reload in watch mode accelerated the development cycle.
- Performance: 2-3x faster test execution meant faster CI/CD pipelines.
// package.json - Installing Vitest
{
"devDependencies": {
"vitest": "^4.0.18",
"@vitejs/plugin-react": "^5.1.3",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1",
"happy-dom": "^15.7.4"
}
}
Quick Start in 5 Minutes
Want to implement this in your project right now? Here's the complete setup:
Step 1: Install Dependencies
npm install -D vitest@^4.0.18 \
@vitejs/plugin-react@^5.1.3 \
@testing-library/react@^16.3.2 \
@testing-library/jest-dom@^6.9.1 \
@testing-library/user-event@^14.6.1 \
happy-dom@^15.7.4
Step 2: Create Configuration Files
vitest.config.ts (root of your project):
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
})
vitest.setup.ts (root of your project):
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
Step 3: Add Test Scripts
Add to your package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Step 4: Write Your First Test
Create src/components/Button.test.tsx:
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
function Button({ children }: { children: string }) {
return <button>{children}</button>
}
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
})
Step 5: Run Tests
npm test
You should see: β
Test Files 1 passed (1) Tests 1 passed (1)
π Done! You now have a working Vitest setup.
Architecture & Setup
Project Structure
The testing infrastructure follows a clean, maintainable architecture:
portfolio-demo/
βββ vitest.config.ts # Vitest configuration
βββ vitest.setup.ts # Global test setup & mocks
βββ src/
β βββ test/
β β βββ utils.tsx # Custom render utilities
β β βββ utils.test.ts # Utility function tests
β βββ components/
β βββ tools/
β βββ EquityCompensationCalculator.tsx
β βββ EquityCompensationCalculator.test.tsx
β βββ SalaryComparison.tsx
β βββ SalaryComparison.test.tsx
Testing Architecture Flow
Here's how the testing infrastructure works:
graph TD
A[npm test] --> B[Vitest Config]
B --> C[Load vitest.setup.ts]
C --> D[Setup Global Mocks]
D --> E[Run Test Files]
E --> F[Component Tests]
E --> G[Utility Tests]
F --> H[Custom Render Utils]
G --> H
H --> I[React Testing Library]
I --> J[happy-dom Environment]
J --> K[Test Results]
K --> L{All Passing?}
L -->|Yes| M[β
Success]
L -->|No| N[β Show Failures]
style A fill:rgb(var(--theme-primary)),stroke:#333,stroke-width:2px,color:#000
style M fill:#00ff00,stroke:#333,stroke-width:2px,color:#000
style N fill:#ff0000,stroke:#333,stroke-width:2px,color:#fff
style J fill:#4a9eff,stroke:#333,stroke-width:2px,color:#fff
Key Flow Points:
- Configuration Layer: Vitest loads config and setup files
- Mock Layer: Global mocks for Next.js dependencies
- Test Execution: Parallel test file execution
- Rendering Layer: Custom utilities wrap React Testing Library
- Environment: happy-dom provides lightweight DOM simulation
- Results: Fast feedback with detailed error reporting
Core Configuration
vitest.config.ts - The foundation of the testing setup:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'.next/',
'**/*.config.{js,ts,mjs}',
'**/dist/',
'**/*.test.{ts,tsx}',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
Key Configuration Decisions:
- happy-dom instead of jsdom (explanation below)
- globals: true - Enables describe/it/expect without imports
- Path aliases - Matches Next.js configuration
- V8 coverage - Faster and more accurate than Istanbul
Overcoming ESM/CommonJS Compatibility Issues
The Challenge
Initial setup with jsdom resulted in cryptic errors:
Error: require() of ES Module /node_modules/@exodus/bytes/encoding-lite.js
from /node_modules/html-encoding-sniffer/lib/html-encoding-sniffer.js not supported.
This is a common pain point when testing modern Next.js applications. The issue stems from:
- Next.js 16 using pure ESM
- jsdom's internal dependencies using CommonJS
- Node.js's strict module system enforcement
The Solution: happy-dom
After researching alternatives, I switched to happy-dom:
npm install -D happy-dom
Why happy-dom wins:
- β Pure ESM - No CommonJS legacy code
- β 2-3x faster than jsdom
- β Smaller bundle - 60% smaller than jsdom
- β Better API support - Modern DOM APIs
// vitest.config.ts
export default defineConfig({
test: {
environment: 'happy-dom', // Changed from 'jsdom'
// ... rest of config
},
})
Result: All ESM compatibility issues resolved instantly.
Writing Effective Component Tests
The Testing Philosophy
I follow the "Test User Behavior, Not Implementation" principle:
// β Bad: Testing implementation details
test('state updates correctly', () => {
const { result } = renderHook(() => useState(0))
// Testing internal state
})
// β
Good: Testing user-visible behavior
test('displays updated salary when input changes', () => {
render(<SalaryCalculator />)
const input = screen.getByLabelText('Annual Salary')
fireEvent.change(input, { target: { value: '150000' } })
expect(screen.getByText(/\$150,000/)).toBeInTheDocument()
})
Real Example: Equity Compensation Calculator
This calculator compares job offers with RSUs/ESOPs, vesting schedules, and risk analysis.
Component Test Suite:
// EquityCompensationCalculator.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@/test/utils'
import EquityCompensationCalculator from './EquityCompensationCalculator'
describe('EquityCompensationCalculator', () => {
it('renders calculator with default values', () => {
render(<EquityCompensationCalculator />)
expect(screen.getByText('Compare Job Offers')).toBeInTheDocument()
expect(screen.getByDisplayValue('Startup Offer')).toBeInTheDocument()
expect(screen.getByDisplayValue('Corporate Offer')).toBeInTheDocument()
})
it('updates offer name when input changes', () => {
render(<EquityCompensationCalculator />)
const offerInput = screen.getByDisplayValue('Startup Offer')
fireEvent.change(offerInput, { target: { value: 'Tech Offer' } })
expect(screen.getByDisplayValue('Tech Offer')).toBeInTheDocument()
})
it('displays scenario comparisons', () => {
render(<EquityCompensationCalculator />)
// Tests that all three financial scenarios render
expect(screen.getByText('Conservative')).toBeInTheDocument()
expect(screen.getByText('Moderate')).toBeInTheDocument()
expect(screen.getByText('Optimistic')).toBeInTheDocument()
})
it('shows vesting timeline heading', () => {
render(<EquityCompensationCalculator />)
expect(screen.getByText(/Vesting Timeline/i)).toBeInTheDocument()
})
})
Common Pitfall: Multiple Element Matches
Problem encountered:
// β This fails when text appears multiple times
expect(screen.getByText(/Health Insurance/i)).toBeInTheDocument()
// Error: Found multiple elements with text matching /Health Insurance/i
Solution:
// β
Use getAllByText when expecting multiple matches
expect(screen.getAllByText(/Health Insurance/i).length).toBeGreaterThan(0)
This happened because "Health Insurance" appeared in:
- The checkbox label
- The benefits breakdown section
- The tooltip hover text
Test Utilities & Mocking Strategies
Custom Render Utility
Created a centralized render function for future provider wrapping:
// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react'
import { ReactElement } from 'react'
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { ...options })
export * from '@testing-library/react'
export { customRender as render }
Benefits:
- Single place to add providers (theme, context, i18n)
- Consistent test setup across all files
- Easy to extend
Mocking Strategy
vitest.setup.ts - Global mocks for Next.js dependencies:
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi, beforeAll } from 'vitest'
import React from 'react'
// Set up test environment variables
beforeAll(() => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'
})
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock Supabase client
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: vi.fn(() => Promise.resolve({ data: null, error: null })),
select: vi.fn(() => Promise.resolve({ data: [], error: null })),
update: vi.fn(() => Promise.resolve({ data: null, error: null })),
delete: vi.fn(() => Promise.resolve({ data: null, error: null })),
})),
})),
}))
// Mock Next.js router
vi.mock('next/navigation', () => ({
useRouter() {
return {
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
pathname: '/',
query: {},
asPath: '/',
}
},
usePathname() {
return '/'
},
useSearchParams() {
return new URLSearchParams()
},
}))
// Mock Next.js Image component
vi.mock('next/image', () => ({
default: (props: any) => {
return React.createElement('img', props)
},
}))
Why These Mocks Matter:
- Supabase Mock: Prevents actual API calls during tests
- Router Mock: Enables testing navigation without full Next.js context
- Image Mock: Avoids Next.js optimization errors in test environment
Real-World Examples
Testing Complex Calculations
Vesting Calculation Test:
// src/test/utils.test.ts
describe('Vesting Calculations', () => {
it('calculates vested shares with cliff', () => {
const totalShares = 10000
const vestingMonths = 48
const cliffMonths = 12
// Before cliff - should vest nothing
const beforeCliff = 6
expect(beforeCliff < cliffMonths ? 0 : totalShares * (beforeCliff / vestingMonths))
.toBe(0)
// After cliff at 24 months - should vest 50%
const afterCliff = 24
const vestedAfterCliff = afterCliff >= cliffMonths
? totalShares * (afterCliff / vestingMonths)
: 0
expect(vestedAfterCliff).toBe(5000)
})
it('caps vesting at 100%', () => {
const totalShares = 10000
const vestingMonths = 48
// After full vesting period (60 months > 48 months)
const months = 60
const vestedPercent = Math.min(months / vestingMonths, 1)
const vested = totalShares * vestedPercent
expect(vested).toBe(10000)
expect(vestedPercent).toBe(1) // Capped at 100%
})
})
Testing Currency Formatting
describe('Currency Utils', () => {
it('formats INR correctly with lakhs separator', () => {
const amount = 1000000
const formatted = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
}).format(amount)
expect(formatted).toContain('10,00,000') // Indian numbering
})
it('formats USD correctly', () => {
const amount = 100000
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(amount)
expect(formatted).toBe('$100,000') // Western numbering
})
})
Performance Optimization
Test Execution Benchmarks
Before Optimization:
- Full suite: ~2.5s
- Individual component: ~800ms
- Setup overhead: 40%
After Optimization:
β
Test execution: ~930ms (63% improvement)
β
Transform time: ~314ms
β
Import time: ~793ms
β
Actual test time: ~435ms
Optimization Techniques
- Parallel Execution (default in Vitest)
- Reduced Setup Overhead - Lazy load heavy dependencies
- Smart Mocking - Mock only what's necessary
- happy-dom - 60% smaller, 2x faster than jsdom
NPM Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Continuous Integration Setup
GitHub Actions Workflow
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run test:coverage
- uses: codecov/codecov-action@v3
Key Takeaways
What I Learned
- Choose the Right Environment: happy-dom > jsdom for modern ESM projects
- Mock Strategically: Global mocks in setup, component-specific in tests
- Test Behavior: Focus on user interactions, not implementation
- Performance Matters: Fast tests = better developer experience
Best Practices Checklist
- β
Use
getAllByTextwhen elements appear multiple times - β Test user-visible behavior, not internal state
- β Mock external dependencies (APIs, routers, images)
- β
Set up environment variables in
beforeAll - β
Clean up after each test with
afterEach - β
Use semantic queries (
getByRole,getByLabelText) - β
Avoid
data-testidwhen possible (accessibility-first)
Metrics That Matter
Coverage Goals:
- Statements: >80%
- Branches: >75%
- Functions: >80%
- Lines: >80%
Current Achievement:
Test Files: 4/4 passing (100%)
Tests: 27/27 passing (100%)
Components Tested: 3/3 (EquityCalculator, SalaryComparison, VacancyCalculator)
Utility Functions: 10/10 tested
Troubleshooting Common Issues
Error: "Cannot find module '@testing-library/jest-dom'"
Cause: Missing import in test files or setup file.
Solution:
// Add to vitest.setup.ts
import '@testing-library/jest-dom'
Error: "ReferenceError: describe is not defined"
Cause: globals: true not set in config.
Solution:
// vitest.config.ts
export default defineConfig({
test: {
globals: true, // Add this
},
})
Error: "Cannot use import statement outside a module"
Cause: ESM/CommonJS mismatch.
Solution: Ensure these settings in vitest.config.ts:
export default defineConfig({
test: {
environment: 'happy-dom', // Use happy-dom, not jsdom
},
})
Error: "Found multiple elements with text matching..."
Cause: Text appears multiple times in rendered output.
Solution: Use getAllByText or be more specific:
// Instead of:
expect(screen.getByText(/Health Insurance/i)).toBeInTheDocument()
// Use:
expect(screen.getAllByText(/Health Insurance/i).length).toBeGreaterThan(0)
// Or target a specific container:
const section = screen.getByRole('region', { name: 'Benefits' })
expect(within(section).getByText(/Health Insurance/i)).toBeInTheDocument()
Error: "Cannot read properties of null (reading 'useContext')"
Cause: Component requires a context provider not present in tests.
Solution: Wrap component in provider:
// src/test/utils.tsx
import { ThemeProvider } from '@/context/ThemeContext'
export function renderWithProviders(ui: ReactElement) {
return render(
<ThemeProvider>
{ui}
</ThemeProvider>
)
}
// In tests:
renderWithProviders(<MyComponent />)
Tests pass locally but fail in CI
Possible causes & solutions:
-
Timezone issues:
// Set timezone in vitest.config.ts beforeAll(() => { process.env.TZ = 'UTC' }) -
Missing environment variables:
# .github/workflows/test.yml env: NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }} -
Outdated dependencies:
npm ci # Use in CI instead of npm install
Frequently Asked Questions (FAQ)
How do I fix "require() of ES Module" errors in Next.js testing?
Switch from jsdom to happy-dom in your vitest.config.ts:
export default defineConfig({
test: {
environment: 'happy-dom', // Instead of 'jsdom'
},
})
happy-dom is pure ESM and doesn't have CommonJS legacy dependencies.
Why choose Vitest over Jest for Next.js 16?
Vitest advantages:
- β Native ESM support (no transforms needed)
- β 2-3x faster test execution
- β Zero-config TypeScript
- β Vite-native (if using Vite in your build pipeline)
- β Built-in UI mode
- β Instant hot-reload in watch mode
Jest advantages:
- β Larger ecosystem
- β More mature (established in 2013)
- β Better third-party integration
For modern Next.js apps using ESM, Vitest is the better choice.
How do I test Next.js Server Actions with Vitest?
Mock server actions in your test file:
import { vi } from 'vitest'
// Mock the server action
vi.mock('@/app/actions', () => ({
submitForm: vi.fn(async () => ({ success: true })),
}))
// In your test
it('calls server action on submit', async () => {
const { submitForm } = await import('@/app/actions')
render(<MyForm />)
fireEvent.click(screen.getByText('Submit'))
await waitFor(() => {
expect(submitForm).toHaveBeenCalledWith(expectedData)
})
})
How do I test components that use Next.js Image?
Add this mock to vitest.setup.ts:
import React from 'react'
import { vi } from 'vitest'
vi.mock('next/image', () => ({
default: (props: any) => {
return React.createElement('img', props)
},
}))
This renders the Image component as a standard <img> tag in tests.
What's the difference between happy-dom and jsdom?
| Feature | happy-dom | jsdom |
|---|---|---|
| Module System | Pure ESM | CommonJS + ESM |
| Speed | 2-3x faster | Baseline |
| Bundle Size | ~60% smaller | Larger |
| DOM API Coverage | Modern APIs | Comprehensive |
| Maintenance | Active | Active |
| Best For | Modern ESM apps | Legacy compatibility |
For Next.js 16, use happy-dom.
How do I achieve 100% test coverage with Vitest?
-
Run coverage analysis:
npm run test:coverage -
Configure coverage thresholds in
vitest.config.ts:coverage: { provider: 'v8', reporter: ['text', 'html'], thresholds: { statements: 80, branches: 75, functions: 80, lines: 80, }, } -
Exclude non-testable files:
coverage: { exclude: [ 'node_modules/', '**/*.config.{js,ts}', '**/dist/', '**/*.test.{ts,tsx}', ], }
Can I use Vitest with the App Router?
Yes! Vitest works perfectly with Next.js App Router. Just ensure:
- Mock
next/navigationhooks (useRouter,usePathname,useSearchParams) - Use
'use client'components for interactive tests - Mock server components if needed
Example in vitest.setup.ts:
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}))
How do I test async components in Next.js?
Use async/await with React Testing Library's waitFor:
import { waitFor } from '@testing-library/react'
it('loads data asynchronously', async () => {
render(<AsyncComponent />)
// Wait for async operation to complete
await waitFor(() => {
expect(screen.getByText('Loaded Data')).toBeInTheDocument()
})
})
Related Content
Want to dive deeper into Next.js development? Check out these guides:
- Achieving a Perfect 100 Lighthouse Score - Optimize your Next.js app for performance
- Migrating from Vite to Next.js 16 - Complete migration guide with real-world examples
- Building a Real-time Analytics Dashboard - Create production-ready dashboards with Next.js
Resources & Further Reading
Official Documentation
Community Resources
Conclusion
Implementing a robust testing infrastructure transformed this portfolio project from "fingers-crossed deploys" to confident, continuous delivery. The investment in proper testing setup paid dividends:
- Zero production bugs post-deployment
- Faster feature development (no manual testing)
- Better code quality (testable code = better architecture)
- Improved documentation (tests as living specs)
The combination of Vitest + React Testing Library + happy-dom proved ideal for modern Next.js applications. While the initial setup required solving ESM compatibility issues, the result is a lightning-fast, reliable testing infrastructure that will scale with the project.
Connect & Collaborate
Found this helpful? I'm Shashank Tripathi, a Senior Software Engineer specializing in scalable web applications. Check out the live project.
Looking for:
- π Freelance opportunities
- πΌ Full-time senior roles
- π€ Collaboration on open-source projects
- π§ Technical consulting
Let's connect:
Published: February 9, 2026 Tags: #Vitest #Testing #NextJS #React #TypeScript #WebDevelopment #TDD