Skip to main content
Testing ensures your web app remains reliable as it grows. This guide covers recommended testing strategies for the Next.js application.
The starter template does not include a test setup by default. Add testing tools when you’re ready to write tests.

Testing Stack Recommendations

Vitest

Fast unit test runner, Vite-poweredWorks great with Bun and Next.js

React Testing Library

Test components the way users interactEncourages accessible patterns

Playwright

End-to-end testing in real browsersTest full user flows

MSW

Mock API requests at the network levelWorks in tests and browser

Setup: Vitest + React Testing Library

1

Install dependencies

bun add -d vitest @testing-library/react @testing-library/jest-dom \
  @vitejs/plugin-react jsdom happy-dom
2

Create Vitest config

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './'),
    },
  },
});
3

Create setup file

vitest.setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});
4

Add test script

package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

Testing Components

Basic Component Test

tests/components/button.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from '@/components/ui/button';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });
  
  it('calls onClick when clicked', async () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click me</Button>);
    
    await userEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });
  
  it('applies variant styles', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-destructive');
  });
  
  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing with Props

tests/components/heading.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Heading } from '@/components/ui/heading';

describe('Heading', () => {
  it('renders as h1 when variant is h1', () => {
    render(<Heading variant="h1">Title</Heading>);
    const heading = screen.getByRole('heading', { level: 1 });
    expect(heading).toBeInTheDocument();
    expect(heading).toHaveTextContent('Title');
  });
  
  it('renders as h2 by default', () => {
    render(<Heading>Default</Heading>);
    expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
  });
  
  it('accepts custom className', () => {
    render(<Heading className="custom-class">Title</Heading>);
    expect(screen.getByRole('heading')).toHaveClass('custom-class');
  });
});

Testing Features

Feature Page Test

tests/features/home.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { HomePage } from '@/features/home/home-page';

describe('HomePage', () => {
  it('renders heading and description', () => {
    render(<HomePage />);
    
    expect(screen.getByRole('heading', { name: /ship faster/i })).toBeInTheDocument();
    expect(screen.getByText(/github copilot agents/i)).toBeInTheDocument();
  });
  
  it('renders logo image', () => {
    render(<HomePage />);
    const logo = screen.getByAltText('Copilot CLI');
    expect(logo).toBeInTheDocument();
  });
});

Testing with Zustand Stores

tests/features/counter.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, beforeEach } from 'vitest';
import { CounterPage } from '@/features/counter/counter-page';
import { useCounterStore } from '@/features/counter/counter-store';

describe('CounterPage', () => {
  beforeEach(() => {
    // Reset store before each test
    useCounterStore.setState({ count: 0 });
  });
  
  it('displays initial count', () => {
    render(<CounterPage />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
  
  it('increments count when + button is clicked', async () => {
    render(<CounterPage />);
    
    await userEvent.click(screen.getByRole('button', { name: '+' }));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
  
  it('decrements count when - button is clicked', async () => {
    useCounterStore.setState({ count: 5 });
    render(<CounterPage />);
    
    await userEvent.click(screen.getByRole('button', { name: '-' }));
    expect(screen.getByText('Count: 4')).toBeInTheDocument();
  });
});

Testing Utilities

Testing the cn() Helper

tests/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { cn } from '@/components/lib/utils';

describe('cn', () => {
  it('merges class names', () => {
    expect(cn('class1', 'class2')).toBe('class1 class2');
  });
  
  it('handles conditional classes', () => {
    expect(cn('base', true && 'included', false && 'excluded'))
      .toBe('base included');
  });
  
  it('resolves Tailwind conflicts', () => {
    expect(cn('p-4', 'p-2')).toBe('p-2');
    expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
  });
  
  it('handles undefined and null', () => {
    expect(cn('class', undefined, null, 'other')).toBe('class other');
  });
});

Testing Theme Components

tests/blocks/theme-toggle.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThemeToggle } from '@/components/blocks/theme/theme-toggle';

// Mock next-themes
vi.mock('next-themes', () => ({
  useTheme: () => ({
    theme: 'light',
    setTheme: vi.fn(),
    resolvedTheme: 'light',
  }),
}));

describe('ThemeToggle', () => {
  it('renders theme toggle button', () => {
    render(<ThemeToggle />);
    expect(screen.getByRole('button', { name: /toggle theme/i })).toBeInTheDocument();
  });
  
  it('opens menu when clicked', async () => {
    render(<ThemeToggle />);
    
    await userEvent.click(screen.getByRole('button'));
    expect(screen.getByText('Light')).toBeInTheDocument();
    expect(screen.getByText('Dark')).toBeInTheDocument();
    expect(screen.getByText('System')).toBeInTheDocument();
  });
});

End-to-End Testing with Playwright

1

Install Playwright

bun add -d @playwright/test
bunx playwright install
2

Create Playwright config

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'bun dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});
3

Write E2E test

e2e/home.spec.ts
import { test, expect } from '@playwright/test';

test('home page loads correctly', async ({ page }) => {
  await page.goto('/');
  
  await expect(page.getByRole('heading', { name: /ship faster/i }))
    .toBeVisible();
  
  await expect(page.getByAltText('Copilot CLI')).toBeVisible();
});

test('theme toggle works', async ({ page }) => {
  await page.goto('/');
  
  // Click theme toggle
  await page.getByRole('button', { name: /toggle theme/i }).click();
  
  // Select dark mode
  await page.getByRole('menuitemradio', { name: 'Dark' }).click();
  
  // Check that dark class is applied
  await expect(page.locator('html')).toHaveClass(/dark/);
});
4

Add test script

package.json
{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

Testing Best Practices

Test User Behavior

Test how users interact, not implementation details
// ✅ Good
screen.getByRole('button', { name: 'Submit' })

// ❌ Bad
screen.getByTestId('submit-btn')

Accessible Queries

Prefer accessible queries in this order:
  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId (last resort)

Isolate Tests

Reset state between tests
beforeEach(() => {
  useStore.setState(initialState);
});

Mock External Deps

Mock APIs, third-party libraries, and services
vi.mock('@/lib/api', () => ({
  fetchData: vi.fn(),
}));

Coverage Goals

CI Integration

Add tests to GitHub Actions:
.github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v1
      
      - name: Install dependencies
        run: bun install
        working-directory: web
      
      - name: Run linter
        run: bun lint
        working-directory: web
      
      - name: Run unit tests
        run: bun test
        working-directory: web
      
      - name: Run E2E tests
        run: bun test:e2e
        working-directory: web

Next Steps

Components

Learn component patterns to test

State Management

Test Zustand stores

Vitest Docs

Official Vitest documentation

Testing Library

React Testing Library docs

Build docs developers (and LLMs) love