Next.js

Testing Next.js Applications

45 min Lesson 24 of 40

Understanding Testing in Next.js

Testing is essential for building reliable, maintainable applications. Next.js applications can be tested at multiple levels: unit tests for individual components and functions, integration tests for feature workflows, and end-to-end tests for complete user journeys. This lesson covers the most popular testing tools and strategies for Next.js applications.

Testing Stack Overview

The recommended testing stack for Next.js includes:

  • Jest: JavaScript testing framework for unit and integration tests
  • React Testing Library: Testing utilities for React components
  • Playwright: End-to-end testing framework for browser automation
  • MSW (Mock Service Worker): API mocking library for testing
Important: Next.js has built-in support for Jest and React Testing Library through the next/jest transformer, making setup easier.

Setting Up Jest and React Testing Library

Install the required dependencies:

# Install testing dependencies
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

# For TypeScript projects
npm install -D @types/jest

Jest Configuration

Create a Jest configuration file:

// jest.config.mjs
import nextJest from 'next/jest.js';

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files
  dir: './',
});

// Custom Jest configuration
const config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    // Handle module aliases
    '^@/(.*)$': '<rootDir>/$1',
  },
  testMatch: [
    '**/__tests__/**/*.[jt]s?(x)',
    '**/?(*.)+(spec|test).[jt]s?(x)',
  ],
  collectCoverageFrom: [
    'app/**/*.{js,jsx,ts,tsx}',
    'components/**/*.{js,jsx,ts,tsx}',
    'lib/**/*.{js,jsx,ts,tsx}',
    '!**/*.d.ts',
    '!**/node_modules/**',
    '!**/.next/**',
  ],
};

export default createJestConfig(config);

Jest Setup File

Create a setup file for custom matchers and global configurations:

// jest.setup.js
import '@testing-library/jest-dom';

// Mock Next.js router
jest.mock('next/navigation', () => ({
  useRouter() {
    return {
      push: jest.fn(),
      replace: jest.fn(),
      prefetch: jest.fn(),
      back: jest.fn(),
      pathname: '/',
      query: {},
      asPath: '/',
    };
  },
  usePathname() {
    return '/';
  },
  useSearchParams() {
    return new URLSearchParams();
  },
}));

Testing React Components

Write unit tests for your components:

// components/Button.tsx
interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary';
}

export default function Button({
  children,
  onClick,
  disabled = false,
  variant = 'primary',
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  );
}

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

describe('Button Component', () => {
  it('renders button with children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByText('Disabled')).toBeDisabled();
  });

  it('applies correct variant class', () => {
    const { rerender } = render(<Button variant="primary">Primary</Button>);
    expect(screen.getByText('Primary')).toHaveClass('btn-primary');

    rerender(<Button variant="secondary">Secondary</Button>);
    expect(screen.getByText('Secondary')).toHaveClass('btn-secondary');
  });
});
Tip: Use screen queries instead of destructuring from render() to ensure queries always work with the latest DOM state.

Testing User Interactions

Test complex user interactions with user-event:

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

describe('LoginForm', () => {
  it('submits form with email and password', async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    render(<LoginForm onSubmit={onSubmit} />);

    // Type in email field
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');

    // Type in password field
    await user.type(screen.getByLabelText(/password/i), 'password123');

    // Click submit button
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    // Wait for form submission
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });

  it('shows validation errors for invalid inputs', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={jest.fn()} />);

    // Try to submit empty form
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    // Check for error messages
    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
  });
});

Mocking API Calls with MSW

Use Mock Service Worker to mock API responses:

# Install MSW
npm install -D msw

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // Mock GET request
  http.get('https://api.example.com/products', () => {
    return HttpResponse.json([
      { id: 1, name: 'Product 1', price: 99.99 },
      { id: 2, name: 'Product 2', price: 149.99 },
    ]);
  }),

  // Mock POST request
  http.post('https://api.example.com/login', async ({ request }) => {
    const { email, password } = await request.json();

    if (email === 'test@example.com' && password === 'password123') {
      return HttpResponse.json(
        { token: 'fake-jwt-token', user: { email } },
        { status: 200 }
      );
    }

    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }),

  // Mock error response
  http.get('https://api.example.com/error', () => {
    return HttpResponse.json(
      { error: 'Server error' },
      { status: 500 }
    );
  }),
];

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// jest.setup.js (add to existing file)
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Testing Server Components

Test Server Components that fetch data:

// app/products/page.test.tsx
import { render, screen } from '@testing-library/react';
import ProductsPage from './page';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';

describe('ProductsPage', () => {
  it('displays products from API', async () => {
    // Render async Server Component
    const ProductsPageResolved = await ProductsPage();
    render(ProductsPageResolved);

    expect(screen.getByText('Product 1')).toBeInTheDocument();
    expect(screen.getByText('Product 2')).toBeInTheDocument();
    expect(screen.getByText('$99.99')).toBeInTheDocument();
  });

  it('displays error message when API fails', async () => {
    // Override default handler for this test
    server.use(
      http.get('https://api.example.com/products', () => {
        return HttpResponse.json(
          { error: 'Failed to fetch' },
          { status: 500 }
        );
      })
    );

    const ProductsPageResolved = await ProductsPage();
    render(ProductsPageResolved);

    expect(screen.getByText(/error loading products/i)).toBeInTheDocument();
  });
});

Testing API Routes

Test Next.js API routes and route handlers:

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

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const category = searchParams.get('category');

  // Fetch products (simplified)
  const products = await fetchProducts(category);

  return NextResponse.json(products);
}

// app/api/products/route.test.ts
import { GET } from './route';
import { NextRequest } from 'next/server';

describe('/api/products', () => {
  it('returns all products when no category specified', async () => {
    const request = new NextRequest('http://localhost:3000/api/products');
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(Array.isArray(data)).toBe(true);
    expect(data.length).toBeGreaterThan(0);
  });

  it('filters products by category', async () => {
    const request = new NextRequest(
      'http://localhost:3000/api/products?category=electronics'
    );
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data.every((p: any) => p.category === 'electronics')).toBe(true);
  });
});
Warning: When testing API routes, make sure to mock any external services or databases to avoid hitting real endpoints during tests.

Setting Up Playwright for E2E Testing

Install and configure Playwright:

# Install Playwright
npm init playwright@latest

# This creates playwright.config.ts and installs browsers

// 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,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

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

Writing E2E Tests

Create end-to-end test scenarios:

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

test.describe('Login Flow', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    // Navigate to login page
    await page.goto('/login');

    // Fill in login form
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');

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

    // Wait for navigation
    await page.waitForURL('/dashboard');

    // Verify we're on dashboard
    expect(page.url()).toContain('/dashboard');
    await expect(page.locator('h1')).toHaveText('Dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', 'wrong@example.com');
    await page.fill('input[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    // Check for error message
    await expect(page.locator('.error-message')).toHaveText(
      'Invalid credentials'
    );
  });
});

Testing Shopping Cart Flow

Test complex user journeys:

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

test.describe('Shopping Cart', () => {
  test('add product to cart and checkout', async ({ page }) => {
    // Browse products
    await page.goto('/products');

    // Click on first product
    await page.click('.product-card:first-child');

    // Verify product page
    await expect(page.locator('h1')).toBeVisible();

    // Add to cart
    await page.click('button:has-text("Add to Cart")');

    // Verify cart badge updates
    await expect(page.locator('.cart-badge')).toHaveText('1');

    // Go to cart
    await page.click('a[href="/cart"]');

    // Verify cart page
    await expect(page.locator('.cart-item')).toHaveCount(1);

    // Proceed to checkout
    await page.click('button:has-text("Checkout")');

    // Fill checkout form
    await page.fill('input[name="name"]', 'John Doe');
    await page.fill('input[name="address"]', '123 Main St');
    await page.fill('input[name="city"]', 'New York');
    await page.fill('input[name="zip"]', '10001');

    // Complete order
    await page.click('button:has-text("Place Order")');

    // Verify success page
    await page.waitForURL('/order-success');
    await expect(page.locator('h1')).toHaveText('Order Confirmed');
  });
});

Testing with Different Viewports

Test responsive behavior:

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

test.describe('Responsive Navigation', () => {
  test('shows mobile menu on small screens', async ({ page }) => {
    // Set mobile viewport
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');

    // Mobile menu button should be visible
    await expect(page.locator('.mobile-menu-button')).toBeVisible();

    // Desktop menu should be hidden
    await expect(page.locator('.desktop-menu')).not.toBeVisible();

    // Click mobile menu
    await page.click('.mobile-menu-button');

    // Menu drawer opens
    await expect(page.locator('.mobile-menu-drawer')).toBeVisible();
  });

  test('shows desktop menu on large screens', async ({ page }) => {
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');

    // Desktop menu should be visible
    await expect(page.locator('.desktop-menu')).toBeVisible();

    // Mobile menu button should be hidden
    await expect(page.locator('.mobile-menu-button')).not.toBeVisible();
  });
});

Snapshot Testing

Use snapshot tests for visual regression testing:

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

test.describe('Visual Regression Tests', () => {
  test('homepage matches snapshot', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveScreenshot('homepage.png');
  });

  test('product page matches snapshot', async ({ page }) => {
    await page.goto('/products/1');

    // Wait for images to load
    await page.waitForLoadState('networkidle');

    await expect(page).toHaveScreenshot('product-page.png');
  });
});
Tip: Run Playwright tests in CI/CD pipelines to catch visual regressions before deploying to production.

Running Tests

Add test scripts to package.json:

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug"
  }
}

# Run unit tests
npm test

# Run tests in watch mode
npm run test:watch

# Generate coverage report
npm run test:coverage

# Run E2E tests
npm run test:e2e

# Run E2E tests with UI
npm run test:e2e:ui

Practice Exercise

Task: Create a comprehensive test suite for a todo application:

  1. Write unit tests for TodoItem and TodoList components
  2. Test adding, completing, and deleting todos with user-event
  3. Mock API calls for fetching and saving todos with MSW
  4. Test the POST /api/todos route handler
  5. Write E2E tests for the complete todo workflow
  6. Test filtering todos by status (all, active, completed)
  7. Add snapshot tests for the todo list UI
  8. Achieve at least 80% code coverage

Bonus: Set up GitHub Actions to run tests automatically on every pull request.

Summary

Key takeaways about testing Next.js applications:

  • Use Jest and React Testing Library for unit and integration tests
  • Mock API calls with MSW to avoid hitting real endpoints
  • Test user interactions with @testing-library/user-event
  • Use Playwright for end-to-end browser testing
  • Test API routes separately from components
  • Implement visual regression testing with screenshots
  • Run tests in CI/CD pipelines for continuous quality assurance
  • Aim for high code coverage but focus on meaningful tests