Next.js Unit Testing
Unit testing is a fundamental practice in modern web development that helps ensure your code works as expected. In this guide, we'll explore how to effectively write and run unit tests for your Next.js applications.
Introduction to Unit Testing in Next.js
Unit testing involves testing individual components, functions, and modules in isolation to verify that each piece of code works correctly. For Next.js applications, unit tests typically focus on:
- React components
- Utility functions
- Hooks
- API route handlers
- State management logic
By testing these units independently, you can catch bugs early, ensure code reliability, and make refactoring safer.
Setting Up Your Testing Environment
Next.js comes with Jest configuration built in when you create a project using create-next-app
. However, you'll need to install a few additional packages for a complete testing setup.
Required Packages
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
Configuration
Create a jest.config.js
file in your project root:
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
// Handle module aliases (if you're using them in your Next.js project)
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/pages/(.*)$': '<rootDir>/pages/$1',
},
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)
Create a jest.setup.js
file:
// Optional: configure or set up a testing framework before each test
import '@testing-library/jest-dom/extend-expect'
Update your package.json
to include test scripts:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"test:watch": "jest --watch"
}
Testing React Components
Let's start with testing a simple React component in Next.js.
Example: Button Component
First, let's create a simple button component:
// components/Button.js
import React from 'react'
const Button = ({ onClick, children, disabled }) => {
return (
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400"
onClick={onClick}
disabled={disabled}
>
{children}
</button>
)
}
export default Button
Now, let's write a unit test for this component:
// __tests__/components/Button.test.js
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button'
describe('Button component', () => {
test('renders button with correct text', () => {
render(<Button>Click me</Button>)
const buttonElement = screen.getByText('Click me')
expect(buttonElement).toBeInTheDocument()
})
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const buttonElement = screen.getByText('Click me')
fireEvent.click(buttonElement)
expect(handleClick).toHaveBeenCalledTimes(1)
})
test('button is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
const buttonElement = screen.getByText('Click me')
expect(buttonElement).toBeDisabled()
})
})
Understanding the Test
- We use
render
to render our component in a test environment screen
gives us access to the virtual DOM- We use query methods like
getByText
to find elements fireEvent
simulates user interactionsexpect
statements verify our expectations
Testing Hooks
Custom hooks are an essential part of React applications. Here's how to test them:
Example: Counter Hook
// hooks/useCounter.js
import { useState } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
Testing the hook:
// __tests__/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react'
import { useCounter } from '@/hooks/useCounter'
describe('useCounter hook', () => {
test('should initialize with default value of 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('should initialize with provided value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('should increment counter', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
test('should decrement counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
test('should reset counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(5)
})
})
Key Points About Testing Hooks
renderHook
is a utility for testing hooks outside of componentsact
ensures that state updates are processed before assertionsresult.current
gives access to the current hook return values
Testing Utility Functions
Utility functions are perfect candidates for unit testing since they're typically pure functions.
Example: Format Date Utility
// utils/dateFormatter.js
export function formatDate(date) {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) {
return 'Invalid date'
}
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
Testing the utility function:
// __tests__/utils/dateFormatter.test.js
import { formatDate } from '@/utils/dateFormatter'
describe('formatDate utility', () => {
test('formats date correctly', () => {
const date = new Date('2023-01-15')
expect(formatDate(date)).toBe('January 15, 2023')
})
test('handles string date input', () => {
expect(formatDate('2023-01-15')).toBe('January 15, 2023')
})
test('returns empty string for null input', () => {
expect(formatDate(null)).toBe('')
})
test('returns "Invalid date" for invalid date input', () => {
expect(formatDate('not-a-date')).toBe('Invalid date')
})
})
Testing API Route Handlers
Next.js API routes can be tested by mocking the request and response objects.
Example: User API Route
// pages/api/users.js
export default function handler(req, res) {
if (req.method === 'GET') {
// In a real app, this would fetch from a database
const users = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
]
return res.status(200).json(users)
} else if (req.method === 'POST') {
const { name } = req.body
if (!name) {
return res.status(400).json({ error: 'Name is required' })
}
// In a real app, this would save to a database
return res.status(201).json({ id: Date.now(), name })
}
return res.status(405).json({ error: 'Method not allowed' })
}
Testing the API route:
// __tests__/pages/api/users.test.js
import handler from '@/pages/api/users'
describe('Users API Endpoint', () => {
test('GET returns list of users', () => {
const req = {
method: 'GET',
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
handler(req, res)
expect(res.status).toHaveBeenCalledWith(200)
expect(res.json).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) })
]))
})
test('POST with valid data creates a user', () => {
const req = {
method: 'POST',
body: { name: 'Test User' }
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
handler(req, res)
expect(res.status).toHaveBeenCalledWith(201)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
id: expect.any(Number),
name: 'Test User'
}))
})
test('POST without name returns 400', () => {
const req = {
method: 'POST',
body: {}
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
handler(req, res)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.any(String)
}))
})
test('Unsupported method returns 405', () => {
const req = {
method: 'PUT'
}
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
}
handler(req, res)
expect(res.status).toHaveBeenCalledWith(405)
})
})
Testing with Data Fetching
Next.js applications often fetch data from external sources. Here's how to test components that use data fetching:
Example: User List Component
// components/UserList.js
import { useState, useEffect } from 'react'
export default function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchUsers() {
try {
const res = await fetch('/api/users')
if (!res.ok) {
throw new Error('Failed to fetch users')
}
const data = await res.json()
setUsers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchUsers()
}, [])
if (loading) return <p>Loading users...</p>
if (error) return <p className="text-red-500">Error: {error}</p>
return (
<div>
<h2 className="text-xl font-bold mb-4">Users</h2>
{users.length === 0 ? (
<p>No users found</p>
) : (
<ul className="space-y-2">
{users.map(user => (
<li key={user.id} className="border p-2 rounded">{user.name}</li>
))}
</ul>
)}
</div>
)
}
Testing with mocked fetch:
// __tests__/components/UserList.test.js
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import UserList from '@/components/UserList'
describe('UserList component', () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
]
beforeAll(() => {
// Mock fetch globally
global.fetch = jest.fn()
})
afterEach(() => {
jest.resetAllMocks()
})
test('renders loading state initially', () => {
// Mock fetch to never resolve during this test
global.fetch = jest.fn(() => new Promise(() => {}))
render(<UserList />)
expect(screen.getByText('Loading users...')).toBeInTheDocument()
})
test('renders users when fetch succeeds', async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockUsers
})
render(<UserList />)
// Wait for the loading state to be replaced with users
await waitFor(() => {
expect(screen.queryByText('Loading users...')).not.toBeInTheDocument()
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
})
test('renders error when fetch fails', async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: false
})
render(<UserList />)
await waitFor(() => {
expect(screen.getByText(/Error:/)).toBeInTheDocument()
})
})
test('renders empty message when no users', async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => []
})
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('No users found')).toBeInTheDocument()
})
})
})
Best Practices for Next.js Unit Testing
-
Keep Tests Simple: Each test should verify a single behavior or feature.
-
Use Mocks Appropriately: Mock external dependencies like API calls, but avoid over-mocking.
-
Test Behavior, Not Implementation: Focus on testing what your code does, not how it does it.
-
Arrange-Act-Assert: Structure your tests with clear setup (arrange), action (act), and verification (assert) phases.
-
Use Data-Test Attributes: Add
data-testid
attributes to make elements easier to select in tests.jsx<button data-testid="submit-button" onClick={handleSubmit}>Submit</button>
Then in tests:
jsxconst submitButton = screen.getByTestId('submit-button')
-
Test Edge Cases: Test not just the happy path but also error states, edge cases, and boundary conditions.
-
Keep Tests Fast: Slow tests discourage frequent test runs. Optimize your tests for speed.
Summary
Unit testing is an essential practice for building reliable Next.js applications. In this guide, we've covered:
- Setting up a testing environment with Jest and React Testing Library
- Testing React components with various interactions and states
- Testing custom hooks with renderHook and act
- Testing utility functions with straightforward assertions
- Testing API route handlers by mocking request and response objects
- Testing components that fetch data by mocking the fetch API
By incorporating these techniques into your development workflow, you'll be better equipped to deliver high-quality, bug-free Next.js applications that are easier to maintain and extend.
Additional Resources
- Jest Documentation
- React Testing Library Documentation
- Next.js Testing Documentation
- Testing JavaScript by Kent C. Dodds
Exercises
- Create a form component that validates input and write comprehensive tests for it.
- Write tests for a custom hook that manages a shopping cart with add, remove, and update functionality.
- Create an API endpoint that handles pagination and write tests for different page requests.
- Write tests for a Next.js page that uses
getServerSideProps
to fetch data. - Extend the Button component from our example with additional features like different sizes and variants, then write tests for these new features.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)