All guides
Engineering13 min read

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.

NC

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

code
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-axe
code
// 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);
code
// 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

code
npm install -D @playwright/test
npx playwright install chromium
code
// 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

code
// 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`;
}
code
// 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

code
// 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),
});
code
// 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:

code
// 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 };
}
code
// 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

code
// 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);
}
code
// 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.

code
// 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:

code
// 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/);
  });
});
code
// 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:

code
// 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:

LayerTarget coveragePriority
Utility functions90%+High
Server Actions80%+High
Route Handlers70%+High
Critical UI components60%+Medium
Page components40%+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.

Insights once a month. No spam. Unsubscribe anytime.