Testing Next.js Applications: The Complete Guide
A practical guide to building a test suite for a Next.js App Router application — covering unit tests, integration tests, component tests, and end-to-end tests.
Nextcraft Agency
Why Testing in Next.js Is Different
The App Router introduces patterns that require updated testing approaches:
- Server Components are async and render on the server — traditional component testing doesn't apply
- Server Actions are functions called from both client and server — they need integration-style testing
- Route Handlers replace API routes from Pages Router — similar testing patterns but different file structure
- Middleware runs in the Edge Runtime — needs separate testing setup
This guide covers testing strategies for each of these, plus the traditional unit and component testing.
Part 1: Setup
Jest Configuration
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-axe
// jest.config.ts
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
dir: './',
});
const config: Config = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testPathPattern: ['**/__tests__/**/*.{ts,tsx}', '**/*.{spec,test}.{ts,tsx}'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.stories.tsx',
'!src/app/layout.tsx',
],
};
export default createJestConfig(config);
// jest.setup.ts
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';
// Polyfills for Next.js server features
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder as typeof TextDecoder;
Playwright Configuration
npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run build && npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
Part 2: Unit Tests
Unit tests cover pure functions in isolation. In a Next.js app, these are primarily utility functions, data transformations, and validation logic.
Testing Utility Functions
// lib/utils/pricing.ts
export function calculateAnnualPrice(monthlyPrice: number, discountPercent: number) {
const annualPrice = monthlyPrice * 12;
return annualPrice * (1 - discountPercent / 100);
}
export function formatPriceDisplay(price: number, interval: 'monthly' | 'annual') {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
return interval === 'annual' ? `${formatted}/year` : `${formatted}/month`;
}
// lib/utils/pricing.test.ts
import { calculateAnnualPrice, formatPriceDisplay } from './pricing';
describe('calculateAnnualPrice', () => {
it('applies discount to annual price', () => {
expect(calculateAnnualPrice(49, 20)).toBe(470.4); // 49 * 12 * 0.8
});
it('handles 0% discount', () => {
expect(calculateAnnualPrice(49, 0)).toBe(588); // 49 * 12
});
});
describe('formatPriceDisplay', () => {
it('formats monthly price', () => {
expect(formatPriceDisplay(49, 'monthly')).toBe('$49.00/month');
});
it('formats annual price', () => {
expect(formatPriceDisplay(470, 'annual')).toBe('$470.00/year');
});
});
Testing Validation Schemas
// lib/schemas/project.ts
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().default(false),
});
// lib/schemas/project.test.ts
import { CreateProjectSchema } from './project';
describe('CreateProjectSchema', () => {
it('validates valid input', () => {
const result = CreateProjectSchema.safeParse({
name: 'My Project',
description: 'A great project',
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
const result = CreateProjectSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toBe('Name is required');
});
it('applies default for isPublic', () => {
const result = CreateProjectSchema.safeParse({ name: 'Test' });
expect(result.success && result.data.isPublic).toBe(false);
});
});
Part 3: Server Action Tests
Server Actions are async functions that run on the server. Test them directly:
// lib/actions/project.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
const schema = z.object({
name: z.string().min(1).max(100),
});
export async function createProject(formData: FormData) {
const user = await requireAuth();
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { success: false as const, fieldErrors: parsed.error.flatten().fieldErrors };
}
const project = await db.project.create({
data: { name: parsed.data.name, ownerId: user.id },
});
return { success: true as const, projectId: project.id };
}
// lib/actions/project.test.ts
import { createProject } from './project';
// Mock auth
jest.mock('@/lib/auth', () => ({
requireAuth: jest.fn().mockResolvedValue({ id: 'user_123', email: 'test@example.com' }),
}));
// Mock database
jest.mock('@/lib/db', () => ({
db: {
project: {
create: jest.fn().mockResolvedValue({ id: 'project_456', name: 'Test Project' }),
},
},
}));
describe('createProject', () => {
it('creates a project with valid data', async () => {
const formData = new FormData();
formData.append('name', 'Test Project');
const result = await createProject(formData);
expect(result.success).toBe(true);
if (result.success) {
expect(result.projectId).toBe('project_456');
}
});
it('returns field errors for empty name', async () => {
const formData = new FormData();
formData.append('name', '');
const result = await createProject(formData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.fieldErrors?.name).toBeDefined();
}
});
});
Part 4: Route Handler Tests
// app/api/projects/route.ts
export async function GET(request: Request) {
const user = await requireAuth(request);
const projects = await db.project.findMany({ where: { ownerId: user.id } });
return Response.json(projects);
}
// app/api/projects/route.test.ts
import { GET } from './route';
jest.mock('@/lib/auth', () => ({
requireAuth: jest.fn().mockResolvedValue({ id: 'user_123' }),
}));
jest.mock('@/lib/db', () => ({
db: {
project: {
findMany: jest.fn().mockResolvedValue([
{ id: '1', name: 'Project One' },
{ id: '2', name: 'Project Two' },
]),
},
},
}));
describe('GET /api/projects', () => {
it('returns projects for authenticated user', async () => {
const request = new Request('http://localhost/api/projects');
const response = await GET(request);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toHaveLength(2);
expect(body[0].name).toBe('Project One');
});
});
Part 5: Component Tests
Use React Testing Library for components. Test behavior (what the user sees and does), not implementation.
// components/features/projects/CreateProjectForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CreateProjectForm } from './CreateProjectForm';
// Mock the Server Action
jest.mock('@/lib/actions/project', () => ({
createProject: jest.fn(),
}));
import { createProject } from '@/lib/actions/project';
describe('CreateProjectForm', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the form', () => {
render(<CreateProjectForm />);
expect(screen.getByLabelText(/project name/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
});
it('shows validation error for empty name', async () => {
const user = userEvent.setup();
render(<CreateProjectForm />);
await user.click(screen.getByRole('button', { name: /create/i }));
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
});
it('calls createProject with form data on submit', async () => {
const user = userEvent.setup();
(createProject as jest.Mock).mockResolvedValue({ success: true, projectId: '1' });
render(<CreateProjectForm />);
await user.type(screen.getByLabelText(/project name/i), 'My New Project');
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(createProject).toHaveBeenCalled();
});
});
});
Part 6: End-to-End Tests
E2E tests run against the real application. They're slow but catch integration issues:
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can sign up and see dashboard', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`);
await page.getByLabel('Password').fill('securepassword123');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText('Welcome!')).toBeVisible();
});
test('user can log in with existing account', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('existing@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('invalid credentials show error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
await expect(page).toHaveURL(/\/login/);
});
});
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test';
// Authenticated state shared across tests
test.use({ storageState: 'playwright/.auth/user.json' });
test('user can create a project', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'New Project' }).click();
await page.getByLabel('Project name').fill('My E2E Project');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('My E2E Project')).toBeVisible();
});
Authentication Setup for E2E
Create a shared auth state once instead of logging in for every test:
// e2e/setup/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Part 7: Test Coverage Strategy
Aim for coverage in areas that matter:
| Layer | Target coverage | Priority |
|---|---|---|
| Utility functions | 90%+ | High |
| Server Actions | 80%+ | High |
| Route Handlers | 70%+ | High |
| Critical UI components | 60%+ | Medium |
| Page components | 40%+ | Low |
Run coverage with jest --coverage and look for uncovered branches in critical paths, not overall percentage. A 95% coverage score that misses your billing logic is worse than 60% that covers everything important.
The right test strategy is one your team can maintain. Start with the highest-value tests (actions, utilities) and add coverage in proportion to risk.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.