Testing Next.js Applications
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
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');
});
});
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);
});
});
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');
});
});
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:
- Write unit tests for TodoItem and TodoList components
- Test adding, completing, and deleting todos with user-event
- Mock API calls for fetching and saving todos with MSW
- Test the POST /api/todos route handler
- Write E2E tests for the complete todo workflow
- Test filtering todos by status (all, active, completed)
- Add snapshot tests for the todo list UI
- 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