Skip to content
Back to Blog
TestingVitestNext.jsReact Testing LibraryTypeScriptWeb Development

Vitest + Next.js 16 Testing Setup: From Zero to 27 Passing Tests (2026)

2026-02-0915 min read
Production-Ready Testing InfrastructureProduction-Ready Testing Infrastructure

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

Skip to Quick Start β†’


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:

FeatureVitestJest
Setup ComplexityMinimal (Vite-native)Requires transforms
Speed2-3x fasterBaseline
ESM SupportNativeRequires configuration
TypeScriptZero-configNeeds ts-jest
Watch ModeInstantSlower rebuilds
UI ModeBuilt-inRequires separate packages

The Decision

I chose Vitest for three critical reasons:

  1. Native ESM Support: Next.js 16 uses ES modules extensively. Vitest handles this without additional configuration.
  2. Developer Experience: Instant hot-reload in watch mode accelerated the development cycle.
  3. 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:

  1. Configuration Layer: Vitest loads config and setup files
  2. Mock Layer: Global mocks for Next.js dependencies
  3. Test Execution: Parallel test file execution
  4. Rendering Layer: Custom utilities wrap React Testing Library
  5. Environment: happy-dom provides lightweight DOM simulation
  6. 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:

  1. happy-dom instead of jsdom (explanation below)
  2. globals: true - Enables describe/it/expect without imports
  3. Path aliases - Matches Next.js configuration
  4. 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:

  1. Next.js 16 using pure ESM
  2. jsdom's internal dependencies using CommonJS
  3. 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:

  1. The checkbox label
  2. The benefits breakdown section
  3. 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:

  1. Supabase Mock: Prevents actual API calls during tests
  2. Router Mock: Enables testing navigation without full Next.js context
  3. 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

  1. Parallel Execution (default in Vitest)
  2. Reduced Setup Overhead - Lazy load heavy dependencies
  3. Smart Mocking - Mock only what's necessary
  4. 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

  1. Choose the Right Environment: happy-dom > jsdom for modern ESM projects
  2. Mock Strategically: Global mocks in setup, component-specific in tests
  3. Test Behavior: Focus on user interactions, not implementation
  4. Performance Matters: Fast tests = better developer experience

Best Practices Checklist

  • βœ… Use getAllByText when 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-testid when 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:

  1. Timezone issues:

    // Set timezone in vitest.config.ts
    beforeAll(() => {
      process.env.TZ = 'UTC'
    })
    
  2. Missing environment variables:

    # .github/workflows/test.yml
    env:
      NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
    
  3. 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?

Featurehappy-domjsdom
Module SystemPure ESMCommonJS + ESM
Speed2-3x fasterBaseline
Bundle Size~60% smallerLarger
DOM API CoverageModern APIsComprehensive
MaintenanceActiveActive
Best ForModern ESM appsLegacy compatibility

For Next.js 16, use happy-dom.

How do I achieve 100% test coverage with Vitest?

  1. Run coverage analysis:

    npm run test:coverage
    
  2. Configure coverage thresholds in vitest.config.ts:

    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    }
    
  3. 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:

  1. Mock next/navigation hooks (useRouter, usePathname, useSearchParams)
  2. Use 'use client' components for interactive tests
  3. 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()
  })
})

Want to dive deeper into Next.js development? Check out these guides:


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

Share this article:
Aesthetic Controller
Original Solid