Next.js Test Coverage
When building robust Next.js applications, it's not just about writing tests—it's about writing tests that thoroughly examine your codebase. Test coverage is a metric that helps you understand how much of your code is being tested. In this guide, we'll explore how to set up, measure, and improve test coverage in your Next.js applications.
What is Test Coverage?
Test coverage is a measurement of how much of your application code is executed when your test suite runs. It helps you identify:
- Which parts of your code are being tested
- Which parts remain untested
- Potential risk areas in your codebase
Coverage is typically measured across several dimensions:
- Statement coverage: The percentage of code statements executed
- Branch coverage: The percentage of conditional branches (if/else) executed
- Function coverage: The percentage of functions called
- Line coverage: The percentage of executable lines executed
Setting Up Test Coverage in Next.js
Next.js projects typically use Jest for testing, which includes built-in coverage reporting capabilities. Let's set up test coverage in a Next.js project:
1. Install Dependencies
If you haven't already set up Jest in your Next.js project, install the necessary dependencies:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
2. Configure Jest for Coverage
Create or update your jest.config.js
file in the root directory:
module.exports = {
collectCoverage: true,
// Specifies which files to collect coverage from
collectCoverageFrom: [
'**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
'!**/coverage/**',
'!jest.config.js',
'!next.config.js',
],
// Output directory for coverage reports
coverageDirectory: 'coverage',
// Minimum threshold enforcement
coverageThreshold: {
global: {
statements: 70,
branches: 70,
functions: 70,
lines: 70,
},
},
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
moduleNameMapper: {
// Handle module aliases
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/pages/(.*)$': '<rootDir>/pages/$1',
// Handle CSS imports
'^.+\\.(css|sass|scss)$': '<rootDir>/__mocks__/styleMock.js',
},
};
3. Create Jest Setup File
Create a jest.setup.js
file in the root of your project:
import '@testing-library/jest-dom';
4. Update package.json
Add a script to run the tests with coverage in your package.json
:
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}
Running Test Coverage Reports
Now you can generate coverage reports by running:
npm run test:coverage
Jest will run your tests and generate a coverage report that looks something like this:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 85.71 | 83.33 | 85.71 | 85.71 |
Button.js| 85.71 | 83.33 | 85.71 | 85.71 | 24
----------|---------|----------|---------|---------|-------------------
Additionally, Jest creates a detailed HTML report in the coverage/lcov-report
directory that you can view in a browser.
Practical Example: Testing a Next.js Component
Let's walk through a practical example of testing a component and measuring its coverage.
Component to Test
Consider this simple Button
component:
// components/Button.jsx
import React from 'react';
const Button = ({ onClick, disabled, type = 'button', children }) => {
const handleClick = (e) => {
if (onClick && !disabled) {
onClick(e);
}
};
const getButtonClass = () => {
return disabled ? 'btn btn-disabled' : 'btn btn-primary';
};
return (
<button
type={type}
className={getButtonClass()}
onClick={handleClick}
disabled={disabled}
>
{children}
</button>
);
};
export default Button;
Writing Tests with Coverage in Mind
Create a test file for the component:
// __tests__/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../components/Button';
describe('Button Component', () => {
test('renders button with children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
test('renders with disabled class when disabled', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-disabled');
expect(screen.getByRole('button')).not.toHaveClass('btn-primary');
});
test('renders with primary class when not disabled', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-primary');
expect(screen.getByRole('button')).not.toHaveClass('btn-disabled');
});
test('renders with correct button type', () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
});
This test suite achieves high coverage because it:
- Tests rendering of the component
- Tests component behavior when clicked
- Tests component behavior when disabled
- Tests the different visual states (classes)
- Tests prop propagation (type attribute)
Improving Test Coverage
Here are strategies to improve your test coverage:
1. Identify Uncovered Areas
After running coverage reports, review untested areas of your code. Focus on:
- Complex conditional logic
- Error handling paths
- Edge cases
2. Write Tests for Different Use Cases
Ensure you test components with:
- Different prop combinations
- Edge case inputs
- Error states
- Loading states
- User interactions
3. Mock External Dependencies
When testing components that rely on external services, APIs, or libraries:
// Example: Testing a component that fetches data
jest.mock('next/router', () => ({
useRouter: () => ({
query: { id: '123' },
push: jest.fn(),
}),
}));
jest.mock('../api/fetchData', () => ({
fetchUserData: jest.fn().mockResolvedValue({ name: 'John Doe' }),
}));
4. Test App Integration with Next.js-specific Features
Test coverage should include Next.js-specific functionality:
// Example: Testing a page with getServerSideProps
import { render } from '@testing-library/react';
import HomePage, { getServerSideProps } from '../pages/index';
test('getServerSideProps returns correct data', async () => {
const context = {
req: { headers: { host: 'localhost:3000' } },
res: {},
query: {},
};
const result = await getServerSideProps(context);
expect(result).toEqual({
props: {
// expected props
},
});
});
test('HomePage renders with props from getServerSideProps', () => {
const props = {
// props returned from getServerSideProps
};
render(<HomePage {...props} />);
// assertions
});
Real-world Application: Testing a Data Fetching Component
Let's consider a more complex example of a component that fetches data:
// components/UserProfile.jsx
import { useState, useEffect } from 'react';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
setError(null);
} catch (err) {
setError(err.message);
setUser(null);
} finally {
setLoading(false);
}
};
if (userId) {
fetchUser();
}
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
{user.bio && <p>Bio: {user.bio}</p>}
</div>
);
};
export default UserProfile;
Testing this component requires covering several states:
// __tests__/UserProfile.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from '../components/UserProfile';
// Mock the global fetch function
global.fetch = jest.fn();
describe('UserProfile Component', () => {
beforeEach(() => {
fetch.mockClear();
});
test('shows loading state initially', () => {
fetch.mockImplementationOnce(() => new Promise((resolve) => {})); // Never resolves to keep loading
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data when fetch succeeds', async () => {
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: 'John Doe', email: '[email protected]', bio: 'Developer' }),
})
);
render(<UserProfile userId="123" />);
// Initial loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('Email: [email protected]')).toBeInTheDocument();
expect(screen.getByText('Bio: Developer')).toBeInTheDocument();
});
test('displays error message when fetch fails', async () => {
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 404,
})
);
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch user')).toBeInTheDocument();
});
});
test('handles network errors', async () => {
fetch.mockImplementationOnce(() => Promise.reject(new Error('Network error')));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
test('shows no user found when user data is null after successful fetch', async () => {
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(null),
})
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('No user found')).toBeInTheDocument();
});
});
test('does not fetch when userId is not provided', () => {
render(<UserProfile />);
expect(fetch).not.toHaveBeenCalled();
});
test('renders user without bio when bio is not provided', async () => {
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: 'Jane Doe', email: '[email protected]' }),
})
);
render(<UserProfile userId="456" />);
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
expect(screen.getByText('Email: [email protected]')).toBeInTheDocument();
expect(screen.queryByText(/Bio:/)).not.toBeInTheDocument();
});
});
This comprehensive test suite covers:
- The initial loading state
- Successful data fetching
- Error handling
- Different data scenarios (with and without optional fields)
- The component's behavior when props are missing
Integrating Coverage with CI/CD
To ensure consistent coverage, integrate testing into your CI/CD pipeline:
GitHub Actions Example
Create a .github/workflows/test.yml
file:
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run test:coverage
# Optional: Upload coverage reports
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
Best Practices for Test Coverage
-
Aim for meaningful coverage, not just high percentages
- 100% coverage doesn't guarantee bug-free code
- Focus on testing business-critical paths
-
Set realistic coverage thresholds
- Start with modest goals (e.g., 70%)
- Gradually increase as your testing matures
-
Combine coverage types
- Unit tests for functions and components
- Integration tests for page-level features
- End-to-end tests for critical user flows
-
Regularly review coverage reports
- Identify areas with low coverage
- Look for patterns in uncovered code
-
Use coverage as a guide, not a goal
- The purpose is to improve code quality, not achieve a number
Common Pitfalls to Avoid
-
Overfitting tests to implementation
- Tests that mirror code too closely may pass but still miss bugs
-
Ignoring edge cases
- Coverage may look good but miss important scenarios
-
Not testing error paths
- Happy path testing leaves vulnerabilities
-
Writing tests that don't assert meaningful behavior
- Tests should verify functionality, not just execute code
Summary
Test coverage is a valuable metric that helps you understand how thoroughly your Next.js application is being tested. By following the steps and examples in this guide, you can:
- Set up Jest to measure coverage in your Next.js project
- Write comprehensive tests that explore different aspects of your components
- Identify and improve areas with low coverage
- Integrate coverage reporting into your development workflow
Remember that coverage is a tool to help you build more robust applications, not an end goal itself. The ultimate purpose is to catch bugs early, ensure your application works as expected, and give you confidence when making changes.
Additional Resources
- Jest Documentation on Coverage
- Testing Library Documentation
- Next.js Testing Documentation
- Codecov for Reporting and Tracking Coverage
Exercises
- Set up test coverage in an existing Next.js project
- Write tests for a component until you achieve at least 90% coverage
- Identify and test edge cases for a form component
- Set up a GitHub Actions workflow that enforces minimum coverage thresholds
- Create a custom Jest reporter that highlights areas with insufficient test coverage
By investing time in test coverage, you're building a foundation for more reliable, maintainable Next.js applications that you can confidently evolve over time.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)