All insights
Engineering7 min read

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.

NC

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.

code
// lib/format.ts
export function formatCurrency(amount: number, currency: string) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}
code
// 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):

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

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

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

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

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',
  },
};

export default createJestConfig(config);
code
// jest.setup.ts
import '@testing-library/jest-dom';

For Playwright:

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

  1. Unit tests for all utility functions and business logic — cheap, fast, high value
  2. Integration tests for Server Actions and API routes — the most dangerous code, needs thorough coverage
  3. Component tests for complex UI with conditional logic — targeted, not exhaustive
  4. 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.

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.