Testing in Next.js - What Should I Know?

You've built your Next.js application, and everything seems to work perfectly on your machine. But as your application grows, you start noticing bugs slipping through, features breaking unexpectedly, and your QA team spending countless hours manually testing every function and taking screenshots. Sound familiar?

Testing can feel overwhelming, especially when you're new to frontend development. With terms like Jest, Vitest, React Testing Library being thrown around, and different types of tests to consider, it's easy to feel lost in the maze of testing terminology and tools.

Why Testing Matters in Next.js Applications

Before diving into the technical details, let's understand why testing is crucial for your Next.js application:

  1. Catch Bugs Early: Testing helps identify issues before they reach production, saving time and resources in the long run.

  2. Confidence in Changes: With a good test suite, you can refactor code and add new features confidently, knowing you haven't broken existing functionality.

  3. Documentation: Tests serve as living documentation, showing how components and features should behave.

  4. Better Code Quality: Writing testable code often leads to better architecture and more maintainable applications.

Understanding Different Types of Tests

Think of testing like quality control in a car factory. You wouldn't just test the final assembled car - you'd test individual components, how they work together, and finally, the complete vehicle. Similarly, in Next.js applications, we have different levels of testing:

1. Unit Tests

These are like testing individual car parts. Unit tests focus on testing the smallest pieces of code in isolation, typically individual functions or components. They're fast, reliable, and help pinpoint issues quickly.

2. Integration Tests

Think of these as testing how different car parts work together. Integration tests verify that multiple components or systems interact correctly, like testing if your form submission properly updates the database and shows a success message.

3. End-to-End (E2E) Tests

This is like test-driving the fully assembled car. E2E tests simulate real user behavior, testing complete features from start to finish, such as a user signing up, creating a post, and viewing it on their profile.

4. Snapshot Tests

These are like taking reference photos of car parts to ensure they haven't changed unexpectedly. Snapshot tests capture the output of your components and alert you when they change.

Essential Testing Tools for Next.js

Let's break down the main tools you'll need in your testing toolkit:

Test Runners: Jest vs Vitest

A test runner is like the engine that powers your testing environment. The two most popular options for Next.js are:

Jest

Jest is the traditional choice and comes pre-configured with many Next.js setups. It's well-documented and has a large ecosystem of extensions.

// Example Jest test
import { sum } from './math';

describe('sum function', () => {
  it('adds two numbers correctly', () => {
    expect(sum(1, 2)).toBe(3);
  });
});
Vitest

Vitest is a newer, faster alternative that's gaining popularity. It's particularly well-suited for Vite-based projects but works great with Next.js too.

// Example Vitest test
import { expect, test } from 'vitest';
import { sum } from './math';

test('adds two numbers correctly', () => {
  expect(sum(1, 2)).toBe(3);
});

React Testing Library (RTL)

React Testing Library is your go-to tool for testing React components. It encourages testing your components the way users interact with them, rather than testing implementation details.

import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';

test('shows error message on invalid login', async () => {
  render(<LoginForm />);
  
  // Find form elements
  const emailInput = screen.getByLabelText(/email/i);
  const passwordInput = screen.getByLabelText(/password/i);
  const submitButton = screen.getByRole('button', { name: /login/i });

  // Interact with form
  fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
  fireEvent.change(passwordInput, { target: { value: 'short' } });
  fireEvent.click(submitButton);

  // Check for error message
  const errorMessage = await screen.findByText(/invalid email format/i);
  expect(errorMessage).toBeInTheDocument();
});

End-to-End Testing Tools

For E2E testing, you have two excellent options:

Cypress

Cypress provides a rich interactive interface for writing and debugging E2E tests:

// Example Cypress test
describe('Login Flow', () => {
  it('successfully logs in with valid credentials', () => {
    cy.visit('/login');
    cy.get('[data-testid="email"]').type('user@example.com');
    cy.get('[data-testid="password"]').type('password123');
    cy.get('button[type="submit"]').click();
    cy.url().should('include', '/dashboard');
  });
});
Playwright

Playwright is Microsoft's answer to E2E testing, offering excellent cross-browser support:

// Example Playwright test
test('login flow', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@example.com');
  await page.fill('[data-testid="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL(/.*dashboard/);
});

Setting Up Your Testing Environment

One of the biggest hurdles developers face is configuring their testing environment. Let's break down the setup process:

Basic Jest Setup

  1. First, install the necessary dependencies:

npm install --save-dev jest @testing-library/react @testing-library/jest-dom
  1. 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',
}

module.exports = createJestConfig(customJestConfig)
  1. Create a jest.setup.js file:

import '@testing-library/jest-dom'

Basic Vitest Setup

  1. Install Vitest and related dependencies:

npm install --save-dev vitest @testing-library/react jsdom @vitejs/plugin-react
  1. Create a vitest.config.ts file:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './vitest.setup.ts',
  },
})
  1. Add test scripts to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest watch",
    "test:coverage": "vitest run --coverage"
  }
}

Writing Your First Tests

Let's start with some practical examples of different types of tests:

1. Testing a Simple Component

// components/Button.tsx
export default function Button({ onClick, children }) {
  return (
    <button onClick={onClick} className="primary-button">
      {children}
    </button>
  );
}

// __tests__/Button.test.tsx
import { render, fireEvent, screen } from '@testing-library/react';
import Button from '../components/Button';

describe('Button Component', () => {
  it('renders children correctly', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick handler when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

2. Testing Components with Navigation

One common challenge is testing components that rely on navigation state. Here's how to handle it:

// pages/RedirectedPage.tsx
import { useSearchParams } from 'next/navigation'

export default function RedirectedPage() {
  const searchParams = useSearchParams()
  const message = searchParams.get('message')

  return (
    <div>
      <h1>Redirected Page</h1>
      {message && <p>{message}</p>}
    </div>
  )
}

// __tests__/RedirectedPage.test.tsx
import { render, screen } from '@testing-library/react'
import { useSearchParams } from 'next/navigation'
import RedirectedPage from '../pages/RedirectedPage'

// Mock the navigation module
jest.mock('next/navigation', () => ({
  useSearchParams: jest.fn()
}))

describe('RedirectedPage', () => {
  it('displays message from URL parameters', () => {
    // Setup the mock implementation
    const mockSearchParams = new URLSearchParams('?message=Hello')
    ;(useSearchParams as jest.Mock).mockReturnValue(mockSearchParams)

    render(<RedirectedPage />)
    expect(screen.getByText('Hello')).toBeInTheDocument()
  })
})

3. Testing API Interactions

When testing components that interact with APIs, you'll want to mock the API calls:

// components/UserProfile.tsx
import { useState, useEffect } from 'react'

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data)
        setLoading(false)
      })
  }, [userId])

  if (loading) return <div>Loading...</div>
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

// __tests__/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import UserProfile from '../components/UserProfile'

// Mock fetch globally
global.fetch = jest.fn()

describe('UserProfile', () => {
  beforeEach(() => {
    ;(fetch as jest.Mock).mockClear()
  })

  it('loads and displays user data', async () => {
    const mockUser = {
      name: 'John Doe',
      email: 'john@example.com'
    }

    // Mock the fetch call
    ;(fetch as jest.Mock).mockImplementationOnce(() =>
      Promise.resolve({
        json: () => Promise.resolve(mockUser)
      })
    )

    render(<UserProfile userId="123" />)

    // Check loading state
    expect(screen.getByText('Loading...')).toBeInTheDocument()

    // Wait for data to load
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument()
    })

    expect(screen.getByText('john@example.com')).toBeInTheDocument()
  })
})

Testing Best Practices

1. Follow the Testing Trophy

Rather than the traditional testing pyramid, Kent C. Dodds suggests following the testing trophy:

  • Static Testing (TypeScript, ESLint): Catches typos and type errors

  • Unit Testing: Tests isolated pieces of code

  • Integration Testing: Tests how pieces work together

  • End-to-End: Tests entire workflows

2. Test User Behavior, Not Implementation

Focus on testing what the user sees and does, not the internal implementation details:

// ❌ Bad: Testing implementation details
test('sets loading state to false after fetch', () => {
  const { result } = renderHook(() => useUserData())
  expect(result.current.loading).toBe(false)
})

// ✅ Good: Testing user behavior
test('shows user data after loading', async () => {
  render(<UserProfile userId="123" />)
  
  // User sees loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument()
  
  // User sees their data
  await screen.findByText('John Doe')
  expect(screen.getByText('john@example.com')).toBeInTheDocument()
})

3. Use Data-testid Sparingly

While data-testid attributes are useful, prefer using accessible queries:

// ❌ Overusing data-testid
<button data-testid="submit-button">Submit</button>

// ✅ Using accessible queries
<button type="submit">Submit</button>

Then in your tests:

// ❌ Less maintainable
const button = screen.getByTestId('submit-button')

// ✅ More maintainable and mirrors user behavior
const button = screen.getByRole('button', { name: /submit/i })

Organize your tests logically using describe blocks:

describe('UserProfile', () => {
  describe('when loading', () => {
    it('shows loading indicator')
    it('hides user information')
  })

  describe('when data is loaded', () => {
    it('displays user name')
    it('displays user email')
  })

  describe('when there is an error', () => {
    it('shows error message')
    it('provides retry button')
  })
})

Common Testing Pitfalls and Solutions

1. Async Testing Issues

When testing async operations, remember to await all promises:

// ❌ Might cause test flakiness
test('loads data', () => {
  render(<DataComponent />)
  expect(screen.getByText('Loaded!')).toBeInTheDocument() // Might fail
})

// ✅ Properly handles async operations
test('loads data', async () => {
  render(<DataComponent />)
  await screen.findByText('Loaded!') // Waits for element to appear
})

2. Memory Leaks

Clean up after your tests to prevent memory leaks:

afterEach(() => {
  jest.clearAllMocks()
  cleanup() // Cleans up the DOM
})

Conclusion

Testing in Next.js doesn't have to be overwhelming. Start small with unit tests for your components, gradually add integration tests for your features, and finally implement end-to-end tests for critical user flows. Remember:

  • Choose the right tools for your needs (Jest/Vitest for running tests, React Testing Library for component testing)

  • Focus on testing user behavior rather than implementation details

  • Write tests that give you confidence in your code

  • Use the testing trophy approach to balance different types of tests

  • Keep your tests maintainable and readable

Additional Resources

By following these guidelines and practices, you'll be well on your way to creating a robust testing strategy for your Next.js applications. Remember that testing is an investment in your application's quality and maintainability - the time you spend writing good tests will pay off many times over in reduced bugs and easier maintenance.

Raymond Yeh

Raymond Yeh

Published on 23 March 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
Setting up Vitest for Next.js 15

Setting up Vitest for Next.js 15

Learn how to set up Vitest with Next.js 15, React Testing Library, and TypeScript. Complete guide with configuration, best practices, and performance optimization tips.

Read Full Story
How to Use Jest with Next.js 15: A Comprehensive Guide

How to Use Jest with Next.js 15: A Comprehensive Guide

Struggling with Jest in Next.js 15? From cryptic module errors to React 19 conflicts, discover comprehensive solutions to make Jest work seamlessly in your modern Next.js projects.

Read Full Story
Vitest vs Jest - Which Should I Use for My Next.js App?

Vitest vs Jest - Which Should I Use for My Next.js App?

Struggling with slow tests in your Next.js app? Dive into a real-world comparison of Jest vs Vitest, with actual performance metrics and migration strategies that won't give you nightmares.

Read Full Story
Loading...