Skip to main content

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

bash
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:

javascript
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:

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

json
"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:

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

jsx
// __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

  1. We use render to render our component in a test environment
  2. screen gives us access to the virtual DOM
  3. We use query methods like getByText to find elements
  4. fireEvent simulates user interactions
  5. expect statements verify our expectations

Testing Hooks

Custom hooks are an essential part of React applications. Here's how to test them:

Example: Counter Hook

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

jsx
// __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

  1. renderHook is a utility for testing hooks outside of components
  2. act ensures that state updates are processed before assertions
  3. result.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

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

js
// __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

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

js
// __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

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

jsx
// __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

  1. Keep Tests Simple: Each test should verify a single behavior or feature.

  2. Use Mocks Appropriately: Mock external dependencies like API calls, but avoid over-mocking.

  3. Test Behavior, Not Implementation: Focus on testing what your code does, not how it does it.

  4. Arrange-Act-Assert: Structure your tests with clear setup (arrange), action (act), and verification (assert) phases.

  5. 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:

    jsx
    const submitButton = screen.getByTestId('submit-button')
  6. Test Edge Cases: Test not just the happy path but also error states, edge cases, and boundary conditions.

  7. 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

Exercises

  1. Create a form component that validates input and write comprehensive tests for it.
  2. Write tests for a custom hook that manages a shopping cart with add, remove, and update functionality.
  3. Create an API endpoint that handles pagination and write tests for different page requests.
  4. Write tests for a Next.js page that uses getServerSideProps to fetch data.
  5. 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! :)