Skip to main content

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:

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

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

javascript
import '@testing-library/jest-dom';

4. Update package.json

Add a script to run the tests with coverage in your package.json:

json
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
}

Running Test Coverage Reports

Now you can generate coverage reports by running:

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

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

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

  1. Tests rendering of the component
  2. Tests component behavior when clicked
  3. Tests component behavior when disabled
  4. Tests the different visual states (classes)
  5. 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:

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

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

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

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

yaml
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

  1. Aim for meaningful coverage, not just high percentages

    • 100% coverage doesn't guarantee bug-free code
    • Focus on testing business-critical paths
  2. Set realistic coverage thresholds

    • Start with modest goals (e.g., 70%)
    • Gradually increase as your testing matures
  3. Combine coverage types

    • Unit tests for functions and components
    • Integration tests for page-level features
    • End-to-end tests for critical user flows
  4. Regularly review coverage reports

    • Identify areas with low coverage
    • Look for patterns in uncovered code
  5. Use coverage as a guide, not a goal

    • The purpose is to improve code quality, not achieve a number

Common Pitfalls to Avoid

  1. Overfitting tests to implementation

    • Tests that mirror code too closely may pass but still miss bugs
  2. Ignoring edge cases

    • Coverage may look good but miss important scenarios
  3. Not testing error paths

    • Happy path testing leaves vulnerabilities
  4. 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:

  1. Set up Jest to measure coverage in your Next.js project
  2. Write comprehensive tests that explore different aspects of your components
  3. Identify and improve areas with low coverage
  4. 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

Exercises

  1. Set up test coverage in an existing Next.js project
  2. Write tests for a component until you achieve at least 90% coverage
  3. Identify and test edge cases for a form component
  4. Set up a GitHub Actions workflow that enforces minimum coverage thresholds
  5. 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! :)