← Back to DevBytes

Testing Next.js Components: From Unit to E2E Tests

Testing Next.js Components: From Unit to E2E Tests

Next.js has become one of the most popular React frameworks, powering everything from personal blogs to enterprise applications. But as your application grows, so does the risk of introducing bugs. A robust testing strategy is essential to maintain confidence in your codebase. This tutorial walks you through the full spectrum of testing Next.js components — from isolated unit tests to comprehensive end-to-end (E2E) tests — with practical examples you can apply immediately.

Why Testing Matters in Next.js

Next.js introduces unique challenges compared to a standard React app. Server-side rendering (SSR), static site generation (SSG), API routes, and the App Router all add complexity. Without tests, regressions can slip through, especially when components behave differently on the server versus the client. A layered testing approach helps you catch issues early, refactor safely, and ship features faster.

The Testing Pyramid for Next.js

Before diving into code, it helps to understand the three main layers of testing:

A healthy test suite has many unit tests, fewer integration tests, and a small number of E2E tests. This balance keeps your suite fast while still covering critical user flows.

Setting Up Your Testing Environment

We will use Jest and React Testing Library for unit and integration tests, and Playwright for E2E tests. Start by installing the necessary dependencies:

npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest

Next, create a jest.config.js file at the root of your project:

const nextJest = require('next/jest');

const createJestConfig = nextJest({
  dir: './',
});

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = createJestConfig(customJestConfig);

Create a jest.setup.js file to extend Jest's matchers:

import '@testing-library/jest-dom';

Add a test script to your package.json:

"scripts": {
  "test": "jest",
  "test:watch": "jest --watch"
}

Writing Unit Tests for Components

Unit tests verify that a single component renders correctly and behaves as expected. Let's start with a simple Button component:

// components/Button.tsx
import React from 'react';

interface ButtonProps {
  label: string;
  onClick?: () => void;
  disabled?: boolean;
}

export default function Button({ label, onClick, disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className="btn">
      {label}
    </button>
  );
}

Now, write a unit test for this component:

// __tests__/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from '@/components/Button';

describe('Button', () => {
  it('renders the label', () => {
    render(<Button label="Click me" />);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    render(<Button label="Submit" onClick={handleClick} />);
    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when the disabled prop is true', () => {
    render(<Button label="Submit" disabled />);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

This test file covers rendering, user interaction, and prop-based behavior. Each test is independent and focused on a single concern.

Testing Components That Use Next.js Features

Next.js components often rely on features like next/link, next/image, or next/navigation. These need special handling in tests. Let's look at a navigation component:

// components/Navbar.tsx
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function Navbar() {
  const pathname = usePathname();

  return (
    <nav>
      <Link href="/" className={pathname === '/' ? 'active' : ''}>Home</Link>
      <Link href="/about" className={pathname === '/about' ? 'active' : ''}>About</Link>
      <Link href="/contact" className={pathname === '/contact' ? 'active' : ''}>Contact</Link>
    </nav>
  );
}

To test this, you need to mock the Next.js modules. Create a mock setup in your test file:

// __tests__/Navbar.test.tsx
import { render, screen } from '@testing-library/react';
import Navbar from '@/components/Navbar';

jest.mock('next/navigation', () => ({
  usePathname: () => '/about',
}));

jest.mock('next/link', () => ({
  __esModule: true,
  default: ({ children, href, ...props }: any) => (
    <a href={href} {...props}>{children}</a>
  ),
}));

describe('Navbar', () => {
  it('highlights the active link', () => {
    render(<Navbar />);
    const aboutLink = screen.getByText('About');
    expect(aboutLink).toHaveClass('active');
  });

  it('does not highlight inactive links', () => {
    render(<Navbar />);
    const homeLink = screen.getByText('Home');
    expect(homeLink).not.toHaveClass('active');
  });
});

By mocking next/navigation and next/link, you can test the component's logic without needing the full Next.js runtime.

Testing Data Fetching Components

Many Next.js components fetch data using fetch, getServerSideProps, or the App Router's fetch caching. Let's test a component that displays a list of users fetched from an API:

// components/UserList.tsx
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

export default function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/users')
      .then((res) => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name} - {user.email}</li>
      ))}
    </ul>
  );
}

Here is the test with mocked fetch responses:

// __tests__/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import UserList from '@/components/UserList';

const mockUsers = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
];

describe('UserList', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('displays users after fetching', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers),
    }) as jest.Mock;

    render(<UserList />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Alice - alice@example.com')).toBeInTheDocument();
      expect(screen.getByText('Bob - bob@example.com')).toBeInTheDocument();
    });
  });

  it('displays an error message on fetch failure', async () => {
    global.fetch = jest.fn().mockResolvedValue({
      ok: false,
      json: () => Promise.resolve({}),
    }) as jest.Mock;

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText(/error: failed to fetch/i)).toBeInTheDocument();
    });
  });
});

This approach lets you test both the success and error paths of your data-fetching logic.

Integration Testing with Mock API Routes

Integration tests verify that multiple parts of your application work together. For example, you might want to test a form component that submits data to an API route. Here is a contact form component:

// components/ContactForm.tsx
import { useState } from 'react';

export default function ContactForm() {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');
  const [status, setStatus] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('Sending...');

    const res = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, message }),
    });

    if (res.ok) {
      setStatus('Message sent successfully!');
      setName('');
      setMessage('');
    } else {
      setStatus('Failed to send message.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Your name"
        value={name}
        onChange={(e) => setName(e.target.value)}
        aria-label="Name"
      />
      <textarea
        placeholder="Your message"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        aria-label="Message"
      />
      <button type="submit">Send</button>
      {status && <p role="status">{status}</p>}
    </form>
  );
}

The integration test simulates the full form submission flow:

// __tests__/ContactForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ContactForm from '@/components/ContactForm';

describe('ContactForm integration', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('submits the form and shows success message', async () => {
    global.fetch = jest.fn().mockResolvedValue({ ok: true }) as jest.Mock;

    render(<ContactForm />);

    await userEvent.type(screen.getByLabelText('Name'), 'Jane Doe');
    await userEvent.type(screen.getByLabelText('Message'), 'Hello there!');
    await userEvent.click(screen.getByRole('button', { name: /send/i }));

    expect(global.fetch).toHaveBeenCalledWith('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Jane Doe', message: 'Hello there!' }),
    });

    expect(screen.getByRole('status')).toHaveTextContent('Message sent successfully!');
  });

  it('shows error message on failure', async () => {
    global.fetch = jest.fn().mockResolvedValue({ ok: false }) as jest.Mock;

    render(<ContactForm />);

    await userEvent.type(screen.getByLabelText('Name'), 'Jane');
    await userEvent.type(screen.getByLabelText('Message'), 'Hi');
    await userEvent.click(screen.getByRole('button', { name: /send/i }));

    expect(screen.getByRole('status')).toHaveTextContent('Failed to send message.');
  });
});

This test verifies that the form correctly captures user input, sends the right payload to the API, and displays the appropriate status message.

Setting Up Playwright for E2E Tests

End-to-end tests simulate real user interactions in an actual browser. Playwright is an excellent choice for Next.js E2E testing. Install it with:

npm install --save-dev @playwright/test
npx playwright install

Create a playwright.config.ts file:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

The webServer configuration automatically starts your Next.js dev server before running tests and stops it afterward.

Writing Your First E2E Test

Create an E2E test that verifies a user can navigate your site and interact with a page. For this example, assume you have a home page with a link to an about page:

// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test';

test('user can navigate from home to about page', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveTitle(/Home/);

  await page.click('text=About');

  await expect(page).toHaveURL(/\/about/);
  await expect(page.locator('h1')).toHaveText('About Us');
});

This test opens the browser, navigates to the home page, clicks the About link, and verifies the URL and heading on the resulting page.

Testing a Complete User Flow with E2E

Let's write a more comprehensive E2E test that covers a contact form submission flow from start to finish:

// e2e/contact.spec.ts
import { test, expect } from '@playwright/test';

test('user can submit the contact form', async ({ page }) => {
  await page.goto('/contact');

  await page.fill('[aria-label="Name"]', 'John Smith');
  await page.fill('[aria-label="Message"]', 'I would like to get in touch.');

  await page.click('button[type="submit"]');

  await expect(page.locator('[role="status"]')).toHaveText(
    'Message sent successfully!'
  );

  // Verify the form fields are cleared
  await expect(page.locator('[aria-label="Name"]')).toHaveValue('');
  await expect(page.locator('[aria-label="Message"]')).toHaveValue('');
});

This test exercises the entire flow: navigating to the page, filling out the form, submitting it, and verifying the outcome — exactly what a real user would do.

Testing API Routes

Next.js API routes deserve their own tests. You can test them directly by importing the handler function. Here is an example API route:

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();

  if (!body.name || !body.message) {
    return NextResponse.json(
      { error: 'Name and message are required' },
      { status: 400 }
    );
  }

  // In a real app, you would save to a database here
  return NextResponse.json({ success: true }, { status: 200 });
}

Test the API route handler directly:

// __tests__/api/contact.test.ts
import { POST } from '@/app/api/contact/route';

function createRequest(body: any) {
  return new Request('http://localhost/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }) as any;
}

describe('POST /api/contact', () => {
  it('returns 200 when name and message are provided', async () => {
    const req = createRequest({ name: 'Alice', message: 'Hello' });
    const res = await POST(req);
    const data = await res.json();

    expect(res.status).toBe(200);
    expect(data.success).toBe(true);
  });

  it('returns 400 when name is missing', async () => {
    const req = createRequest({ message: 'Hello' });
    const res = await POST(req);
    const data = await res.json();

    expect(res.status).toBe(400);
    expect(data.error).toBe('Name and message are required');
  });

  it('returns 400 when message is missing', async () => {
    const req = createRequest({ name: 'Alice' });
    const res = await POST(req);
    const data = await res.json();

    expect(res.status).toBe(400);
    expect(data.error).toBe('Name and message are required');
  });
});

Testing API routes directly is fast and gives you precise control over inputs and outputs.

Testing Server Components in the App Router

The App Router introduces React Server Components, which cannot be tested with React Testing Library directly because they run on the server. Instead, you should test the rendered output or extract logic into testable functions. Here is a server component that fetches and displays products:

// app/products/page.tsx
interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

Extract the data-fetching logic and test it separately:

// lib/products.ts
export async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}
// __tests__/lib/products.test.ts
import { getProducts } from '@/lib/products';

describe('getProducts', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('returns products on success', async () => {
    const mockProducts = [
      { id: 1, name: 'Widget', price: 9.99 },
      { id: 2, name: 'Gadget', price: 19.99 },
    ];

    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockProducts),
    }) as jest.Mock;

    const result = await getProducts();
    expect(result).toEqual(mockProducts);
  });

  it('throws an error when fetch fails', async () => {
    global.fetch = jest.fn().mockResolvedValue({ ok: false }) as jest.Mock;

    await expect(getProducts()).rejects.toThrow('Failed to fetch products');
  });
});

For testing the rendered output of server components, rely on E2E tests with Playwright, which will exercise the full server rendering pipeline.

Best Practices for Testing Next.js Applications

1. Test Behavior, Not Implementation

Focus on what the component does from the user's perspective, not how it is implemented internally. This makes your tests more resilient to refactoring. Query elements by their accessible roles and labels rather than by CSS classes or test IDs when possible.

2. Keep Unit Tests Fast and Isolated

Unit tests should run in milliseconds. Mock external dependencies like API calls, routers, and databases. Avoid testing multiple components together in a unit test — that is what integration tests are for.

3. Use Meaningful Test Descriptions

Write test descriptions that read like specifications. Instead of it('works'), use it('displays an error message when the form submission fails'). This makes failures easier to diagnose and serves as living documentation.

4. Mock Next.js Modules Consistently

Create a shared mock file for Next.js modules like next/navigation, next/link, and next/image to reuse across tests. This reduces duplication and keeps your mocks consistent.

// __mocks__/next.js
export const mockRouter = {
  push: jest.fn(),
  replace: jest.fn(),
  back: jest.fn(),
  pathname: '/',
  query: {},
};

jest.mock('next/navigation', () => ({
  useRouter: () => mockRouter,
  usePathname: () => mockRouter.pathname,
  useSearchParams: () => new URLSearchParams(),
}));

5. Run Tests in CI

Integrate your test suite into your CI pipeline. Run unit and integration tests on every pull request, and run E2E tests on staging deployments. This catches regressions before they reach production.

6. Don't Over-Test

Not every component needs a test. Prioritize testing components with complex logic, critical user flows, and areas prone to regression. Simple presentational components with no logic may not need dedicated tests.

7. Use Snapshot Testing Sparingly

Snapshot tests can catch unintended changes, but they are easy to bypass by simply updating the snapshot. Use them for stable output like serialized data or configuration, not for complex component trees.

8. Test Accessibility

Use @testing-library/jest-dom matchers and Playwright's accessibility testing tools to verify that your components are accessible. Check for proper ARIA roles, keyboard navigation, and focus management.

// Example accessibility check in Playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('home page has no accessibility violations', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

Organizing Your Test Files

A clear directory structure makes your test suite maintainable. Here is a recommended layout:

project-root/
├── __tests__/
│   ├── components/
│   │   ├── Button.test.tsx
│   │   ├── Navbar.test.tsx
│   │   └── ContactForm.test.tsx
│   ├── lib/
│   │   └── products.test.ts
│   └── api/
│       └── contact.test.ts
├── e2e/
│   ├── navigation.spec.ts
│   └── contact.spec.ts
├── __mocks__/
│   └── next.js
├── components/
├── app/
└── lib/

Keeping unit tests in __tests__ and E2E tests in e2e separates concerns and makes it easy to run each suite independently.

Running and Debugging Tests

Run your unit tests with npm test. For debugging a specific test, use the --watch flag and filter by file name or test name. Playwright offers a powerful UI mode for debugging E2E tests:

npx playwright test --ui

This opens an interactive interface where you can step through tests, inspect the DOM, and watch the browser execute each action in real time.

To generate test code by recording your interactions, use:

npx playwright codegen http://localhost:3000

This is especially useful when writing E2E tests for complex flows, as it produces working selectors and actions automatically.

Conclusion

Testing Next.js components effectively requires a layered approach that spans unit tests, integration tests, and end-to-end tests. By combining Jest and React Testing Library for fast, isolated component testing with Playwright for realistic browser-based E2E testing, you can build a comprehensive safety net around your application. Remember to test behavior rather than implementation details, mock Next.js-specific modules consistently, and prioritize critical user flows in your E2E suite. With these practices in place, you will catch bugs earlier, refactor with confidence, and deliver a more reliable experience to your users. Start small by adding tests to your most critical components, and gradually expand coverage as your application grows — the investment will pay dividends in stability and developer productivity for the life of your project.

— Ad —

Google AdSense will appear here after approval

← Back to all articles