Testing Next.js Applications: What Actually Needs Tests
100% test coverage is a metric, not a goal. Here's how to write tests that prevent the bugs that actually matter in a Next.js application.
Nextcraft Engineering Team
The Testing Philosophy That Scales
Tests have a cost: they take time to write, they require maintenance when code changes, and they slow down the feedback loop when they're slow or flaky. Good tests pay back their cost many times over by catching bugs before they reach production. Bad tests are overhead without benefit.
The question isn't "how much test coverage?" It's "which behaviors, if broken, would cause the most damage?" Test those, thoroughly. Test other things proportionally to their risk.
What to Test in a Next.js App
Unit tests: Pure functions, utility functions, data transformations. These are cheap to write, fast to run, and rarely need maintenance. Any function that takes input and returns output with no side effects is a good unit test candidate.
// lib/format.ts
export function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
// lib/format.test.ts
import { formatCurrency } from './format';
describe('formatCurrency', () => {
it('formats USD correctly', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
it('formats GBP correctly', () => {
expect(formatCurrency(1000, 'GBP')).toBe('£1,000.00');
});
it('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
});
Integration tests for Server Actions and API routes: These are the highest-risk code paths — they mutate data, they're called by clients, they need to handle error cases. Test the full behavior including database interaction (using a test database or transaction-based rollback):
// lib/actions/create-project.test.ts
import { createProject } from './create-project';
describe('createProject', () => {
it('creates a project for authenticated user', async () => {
const formData = new FormData();
formData.append('name', 'Test Project');
const result = await createProject(formData);
expect(result.success).toBe(true);
expect(result.projectId).toBeDefined();
// Verify it's in the database
const project = await db.project.findUnique({ where: { id: result.projectId } });
expect(project?.name).toBe('Test Project');
});
it('returns validation error for empty name', async () => {
const formData = new FormData();
formData.append('name', '');
const result = await createProject(formData);
expect(result.success).toBe(false);
expect(result.fieldErrors?.name).toBeDefined();
});
});
Component tests for critical UI: Use React Testing Library for components that have complex conditional rendering or user interaction logic:
// components/features/billing/UpgradePrompt.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UpgradePrompt } from './UpgradePrompt';
describe('UpgradePrompt', () => {
it('shows feature-specific copy', () => {
render(<UpgradePrompt feature="advanced_analytics" />);
expect(screen.getByText(/analytics/i)).toBeInTheDocument();
});
it('opens upgrade modal on CTA click', () => {
const mockOpen = jest.fn();
render(<UpgradePrompt feature="advanced_analytics" onUpgrade={mockOpen} />);
fireEvent.click(screen.getByRole('button', { name: /upgrade/i }));
expect(mockOpen).toHaveBeenCalledWith('advanced_analytics');
});
});
End-to-end tests for critical user journeys: Use Playwright or Cypress for the flows that, if broken, immediately affect revenue:
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('user can complete checkout', async ({ page }) => {
await page.goto('/pricing');
await page.getByRole('button', { name: 'Start free trial' }).first().click();
// Fill sign-up form
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('securepassword123');
await page.getByRole('button', { name: 'Create account' }).click();
// Verify onboarding starts
await expect(page.getByText('Welcome!')).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
});
Testing Server Components
Server Components are async functions — you can test them directly:
// app/dashboard/page.test.tsx
import { render } from '@testing-library/react';
import DashboardPage from './page';
// Mock the data layer
jest.mock('@/lib/queries/project', () => ({
getUserProjects: jest.fn().mockResolvedValue([
{ id: '1', name: 'Test Project', status: 'active' },
]),
}));
it('renders project list', async () => {
const jsx = await DashboardPage({
params: Promise.resolve({}),
searchParams: Promise.resolve({}),
});
const { getByText } = render(jsx);
expect(getByText('Test Project')).toBeInTheDocument();
});
What Not to Test
Implementation details: Don't test how a component is implemented internally — test what it renders and what it does in response to user interaction. Tests coupled to implementation need updates whenever you refactor, even when behavior hasn't changed.
Third-party libraries: Don't test that React hooks work, that Prisma queries work, that Stripe's SDK works. Test your code's behavior; trust libraries to test their own.
Snapshot tests: Snapshot tests catch visual regressions but also catch intentional changes — they become noisy quickly and get mass-updated without review. Prefer explicit assertions over snapshots.
Test Configuration in Next.js
// 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',
},
};
export default createJestConfig(config);
// jest.setup.ts
import '@testing-library/jest-dom';
For Playwright:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run build && npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
The Testing Hierarchy
Write the tests that give you the most confidence per unit of effort:
- Unit tests for all utility functions and business logic — cheap, fast, high value
- Integration tests for Server Actions and API routes — the most dangerous code, needs thorough coverage
- Component tests for complex UI with conditional logic — targeted, not exhaustive
- E2E tests for the 3–5 journeys that directly generate revenue — expensive but worth it for the critical path
Everything else: manual testing on PR, monitored in production.
Continue reading
Related articles
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.