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-powered Works great with Bun and Next.js
React Testing Library Test components the way users interact Encourages accessible patterns
Playwright End-to-end testing in real browsers Test full user flows
MSW Mock API requests at the network level Works in tests and browser
Setup: Vitest + React Testing Library
Install dependencies
bun add -d vitest @testing-library/react @testing-library/jest-dom \
@vitejs/plugin-react jsdom happy-dom
Create Vitest config
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 , './' ),
},
} ,
}) ;
Create setup file
import '@testing-library/jest-dom' ;
import { cleanup } from '@testing-library/react' ;
import { afterEach } from 'vitest' ;
afterEach (() => {
cleanup ();
});
Add test script
{
"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
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
Install Playwright
bun add -d @playwright/test
bunx playwright install
Create Playwright config
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 ,
} ,
}) ;
Write E2E test
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/ );
});
Add test script
{
"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:
getByRole
getByLabelText
getByPlaceholderText
getByText
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
Unit Tests (80%+ coverage)
All utilities (cn, helpers)
UI components (Button, Card, etc.)
Business logic functions
Zustand stores
Integration Tests (60%+ coverage)
Feature pages
Complex component interactions
Form submissions
E2E Tests (Critical Paths)
User authentication flows
Core user journeys
Payment flows (if applicable)
# Run all tests
bun test
# Watch mode
bun test --watch
# Coverage report
bun test:coverage
# E2E tests
bun test:e2e
# E2E in UI mode
bun test:e2e:ui
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