Next.js Lesson 27 – Testing Next.js Apps | Dataplexa
Lesson 27

Testing Next.js Apps

Build reliable tests for your Next.js components, API routes, and pages to catch bugs before users do

Testing Next.js apps goes beyond regular React testing. You have server-side rendering, API routes, and static generation to verify. Unlike plain React apps, your Next.js tests need to cover both client-side components and server-side functionality. Think of testing like quality control in a factory. Each part gets checked individually. Then you test how they work together. A car factory tests the brakes separately, then tests the whole car on the road. Your NewsWave site needs the same thoroughness. Modern web apps ship broken features daily. Users expect articles to load instantly. Search functionality must return results. Newsletter signups should work flawlessly. One broken component ruins the entire experience.

Understanding Next.js Testing Layers

Next.js apps have multiple layers that need testing. Each layer serves a different purpose and requires different tools. You cannot test everything with one approach. Unit tests check individual functions and components in isolation. They run fast and catch logic errors. Think of testing a single LEGO brick for cracks. Integration tests verify multiple components working together. They catch bugs that happen when components interact. Like testing if two LEGO bricks connect properly. End-to-end tests simulate real user behavior from start to finish. They test the complete user journey. Like building the entire LEGO castle and making sure it stands. API route testing validates your backend endpoints. These tests ensure your data flows correctly between frontend and backend. Newsletter signups must save to the database. Article fetching must return proper JSON.

Unit Tests

Test individual components and functions in isolation

Integration Tests

Test components working together

E2E Tests

Test complete user journeys

API Tests

Test backend endpoints and data flow

Setting Up Jest and React Testing Library

Jest handles the test running and assertions. React Testing Library helps you test components like a user would interact with them. Together they form the foundation of Next.js testing. React Testing Library changed how developers think about testing. Instead of testing implementation details, you test behavior. You click buttons, fill forms, and check what appears on screen. Jest comes with powerful features for Next.js. It can mock modules, simulate different environments, and generate coverage reports. The combination gives you everything needed for comprehensive testing.
# Install testing dependencies for NewsWave
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
# Install additional helpers for Next.js testing  
npm install --save-dev @testing-library/user-event jest-environment-jsdom
Terminal
$ npm install --save-dev jest @testing-library/react @testing-library/jest-dom
+ jest@29.5.0
+ @testing-library/react@14.0.0
+ @testing-library/jest-dom@6.1.0
✓ Testing dependencies installed

What just happened?

You installed Jest for running tests, React Testing Library for component testing, and jest-dom for additional matchers. These tools work together to create a complete testing environment. Try this: Check your package.json devDependencies section.

Create a Jest configuration file to tell Jest how to handle Next.js files and modules:
// jest.config.js - Configure Jest for Next.js testing
const nextJest = require('next/jest')

// Create Jest config that works with Next.js
const createJestConfig = nextJest({
  dir: './', // Path to Next.js app
})

// Custom Jest configuration
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom', // Browser-like environment
}
Now create a setup file that configures testing utilities for every test:
// jest.setup.js - Global test configuration
import '@testing-library/jest-dom' // Adds custom matchers like toBeInTheDocument

// Mock Next.js router for all tests
jest.mock('next/router', () => ({
  useRouter: () => ({
    push: jest.fn(),
    pathname: '/',
    query: {},
  }),
}))
Add test scripts to your package.json to run tests easily:
// package.json - Add testing scripts
{
  "scripts": {
    "test": "jest", // Run all tests once
    "test:watch": "jest --watch", // Run tests in watch mode
    "test:coverage": "jest --coverage" // Run tests with coverage report
  }
}

Writing Component Tests

Component testing verifies that your React components render correctly and handle user interactions. You test what users see and do, not internal implementation details. The key principle: test behavior, not implementation. Users do not care about state variables or function names. They care that clicking a button shows content or submitting a form saves data. Start with the simplest component. A NewsWave article card should display the title, author, and publish date. Users should be able to click it to navigate to the full article.
// components/ArticleCard.js - Simple article card component
export default function ArticleCard({ article }) {
  return (
    <div className="article-card">
      <h3>{article.title}</h3>
      <p>By {article.author}</p>
      <span>{article.publishedAt}</span>
    </div>
  )
}
localhost:3000 — NewsWave
Now write a test that verifies this component displays article data correctly:
// __tests__/ArticleCard.test.js - Test the article card component
import { render, screen } from '@testing-library/react'
import ArticleCard from '../components/ArticleCard'

// Mock article data for testing
const mockArticle = {
  title: 'Next.js Testing Best Practices',
  author: 'Jane Developer', 
  publishedAt: 'March 10, 2024'
}
// Test that component renders article information
test('displays article title, author, and date', () => {
  render(<ArticleCard article={mockArticle} />)
  
  // Check if title appears on screen
  expect(screen.getByText('Next.js Testing Best Practices')).toBeInTheDocument()
  // Check if author appears on screen  
  expect(screen.getByText('By Jane Developer')).toBeInTheDocument()
  // Check if date appears on screen
  expect(screen.getByText('March 10, 2024')).toBeInTheDocument()
})
Terminal
$ npm test ArticleCard.test.js
PASS __tests__/ArticleCard.test.js
✓ displays article title, author, and date (24ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total

What just happened?

You created a test that renders the ArticleCard component with mock data and verifies the content appears correctly. The test uses screen.getByText to find elements and expects them to be in the document. Try this: Run the test and watch it pass.

Test user interactions by simulating clicks, form submissions, and keyboard events:
// Test user interaction with the search component
import userEvent from '@testing-library/user-event'

test('search form submits with user input', async () => {
  const user = userEvent.setup() // Setup user event simulation
  const mockOnSearch = jest.fn() // Mock function to track calls
  
  render(<SearchForm onSearch={mockOnSearch} />)
  
  // Find search input and button
  const searchInput = screen.getByPlaceholderText('Search articles...')
  const searchButton = screen.getByRole('button', { name: /search/i })
}
  // Simulate user typing in search box
  await user.type(searchInput, 'Next.js testing')
  // Simulate user clicking search button  
  await user.click(searchButton)
  
  // Verify the search function was called with correct term
  expect(mockOnSearch).toHaveBeenCalledWith('Next.js testing')
  expect(mockOnSearch).toHaveBeenCalledTimes(1)
})

Testing API Routes

API route testing ensures your backend endpoints return correct data and handle errors properly. Unlike component tests, these focus on HTTP requests, database operations, and JSON responses. Next.js API routes run on the server, but you can test them without starting a full server. You create mock requests, call your route handlers directly, and verify the responses. Testing API routes catches critical bugs before deployment. A broken newsletter signup loses subscribers. A faulty search endpoint frustrates users. Database connection errors crash your entire app.
// pages/api/newsletter.js - API route for newsletter signups
export default async function handler(req, res) {
  // Only allow POST requests for newsletter signup
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }
  
  const { email } = req.body
  
  // Validate email format
  if (!email || !email.includes('@')) {
    return res.status(400).json({ error: 'Valid email required' })
  }
  try {
    // Simulate saving email to database
    await saveEmailToDatabase(email)
    
    // Return success response
    res.status(200).json({ 
      success: true, 
      message: 'Subscribed successfully' 
    })
  } catch (error) {
    // Handle database errors
    res.status(500).json({ error: 'Subscription failed' })
  }
}
Create a comprehensive test suite that covers all scenarios your API route might encounter:
// __tests__/api/newsletter.test.js - Test newsletter API route
import handler from '../../pages/api/newsletter'
import { createMocks } from 'node-mocks-http'

// Mock the database function
jest.mock('../../lib/database', () => ({
  saveEmailToDatabase: jest.fn()
}))

describe('/api/newsletter', () => {
  test('successfully subscribes valid email', async () => {
    // Create mock request and response objects
    const { req, res } = createMocks({
      method: 'POST',
      body: { email: 'user@newswave.com' }
    })
    // Call the API route handler
    await handler(req, res)
    
    // Verify successful response
    expect(res._getStatusCode()).toBe(200)
    expect(JSON.parse(res._getData())).toEqual({
      success: true,
      message: 'Subscribed successfully'
    })
  })
})
Terminal
$ npm test api/newsletter.test.js
PASS __tests__/api/newsletter.test.js
✓ successfully subscribes valid email (18ms)
Test Suites: 1 passed, 1 total
Test error scenarios to ensure your API handles bad requests gracefully:
  test('rejects invalid email addresses', async () => {
    const { req, res } = createMocks({
      method: 'POST',
      body: { email: 'invalid-email' } // Missing @ symbol
    })
    
    await handler(req, res)
    
    // Verify error response
    expect(res._getStatusCode()).toBe(400)
    expect(JSON.parse(res._getData())).toEqual({
      error: 'Valid email required'
    })
  })
  test('rejects non-POST requests', async () => {
    const { req, res } = createMocks({
      method: 'GET' // Wrong HTTP method
    })
    
    await handler(req, res)
    
    // Verify method not allowed response
    expect(res._getStatusCode()).toBe(405)
    expect(JSON.parse(res._getData())).toEqual({
      error: 'Method not allowed'
    })
  })

What just happened?

You tested your API route with different scenarios including success, validation errors, and wrong HTTP methods. The createMocks function simulates HTTP requests without starting a server. Try this: Add a test for database connection failures.

Testing Pages and Routing

Page testing verifies that your Next.js pages render correctly with proper data and handle routing scenarios. Pages combine components, data fetching, and navigation logic that all need testing. Next.js pages have unique challenges. They might use getStaticProps, getServerSideProps, or dynamic routing. Each pattern requires different testing approaches. Page tests often need more setup than component tests. You must mock Next.js router, simulate data fetching functions, and handle dynamic routes. But the effort pays off by catching integration bugs.
// pages/articles/[slug].js - Dynamic article page  
import { useRouter } from 'next/router'

export default function ArticlePage({ article }) {
  const router = useRouter()
  
  // Show loading state while router loads
  if (router.isFallback) {
    return <div>Loading article...</div>
  }
  
  // Show error if article not found
  if (!article) {
    return <div>Article not found</div>
  }
  return (
    <article>
      <h1>{article.title}</h1>
      <p>By {article.author} on {article.publishedAt}</p>
      <div>{article.content}</div>
      <button onClick={() => router.back()}>
        Back to Articles
      </button>
    </article>
  )
}
Test the page with different data scenarios and router states:
// __tests__/pages/article-page.test.js - Test article page
import { render, screen } from '@testing-library/react'
import { useRouter } from 'next/router'
import ArticlePage from '../../pages/articles/[slug]'

// Mock Next.js router
jest.mock('next/router', () => ({
  useRouter: jest.fn()
}))

const mockPush = jest.fn()
const mockBack = jest.fn()
describe('ArticlePage', () => {
  beforeEach(() => {
    // Reset router mock before each test
    useRouter.mockReturnValue({
      query: { slug: 'test-article' },
      push: mockPush,
      back: mockBack,
      isFallback: false
    })
  })
  
  test('displays article content when loaded', () => {
    const mockArticle = {
      title: 'Understanding Next.js Testing',
      author: 'Test Author',
      publishedAt: 'March 2024',
      content: 'Article content here...'
    }
    render(<ArticlePage article={mockArticle} />)
    
    // Verify article content appears
    expect(screen.getByText('Understanding Next.js Testing')).toBeInTheDocument()
    expect(screen.getByText('By Test Author on March 2024')).toBeInTheDocument()
    expect(screen.getByText('Article content here...')).toBeInTheDocument()
  })
  
  test('shows loading state during fallback', () => {
    // Mock fallback state
    useRouter.mockReturnValue({
      query: {},
      isFallback: true
    })
    render(<ArticlePage article={null} />)
    
    // Verify loading message appears
    expect(screen.getByText('Loading article...')).toBeInTheDocument()
  })
  
  test('handles back button click', async () => {
    const user = userEvent.setup()
    
    render(<ArticlePage article={mockArticle} />)
    
    // Click back button
    await user.click(screen.getByText('Back to Articles'))
    
    // Verify router.back was called
    expect(mockBack).toHaveBeenCalledTimes(1)
  })
})
Terminal
$ npm test pages/article-page.test.js
PASS __tests__/pages/article-page.test.js
✓ displays article content when loaded (25ms)
✓ shows loading state during fallback (12ms)
✓ handles back button click (31ms)
Test Suites: 1 passed, 1 total

End-to-End Testing with Playwright

End-to-end testing simulates real user workflows from start to finish. These tests run in actual browsers and verify that your entire application works together correctly. Playwright has become the modern choice for E2E testing. It supports multiple browsers, handles modern web features, and provides reliable test execution. Unlike older tools, Playwright rarely produces flaky tests. E2E tests catch bugs that unit and integration tests miss. They verify that your NewsWave site actually works for real users. Can visitors browse articles? Does search return results? Can users subscribe to newsletters?
# Install Playwright for end-to-end testing
npm install --save-dev @playwright/test
# Initialize Playwright configuration
npx playwright install
Terminal
$ npx playwright install
Downloading Chromium 119.0.6045.9
Downloading Firefox 119.0
Downloading Webkit 17.4
✓ Browsers installed successfully
Create a Playwright test that walks through a complete user journey:
// e2e/newsletter-signup.spec.js - Test complete newsletter signup flow
import { test, expect } from '@playwright/test'

test('user can subscribe to newsletter', async ({ page }) => {
  // Navigate to NewsWave homepage
  await page.goto('http://localhost:3000')
  
  // Verify page loaded correctly
  await expect(page).toHaveTitle(/NewsWave/)
  
  // Find newsletter signup form
  const emailInput = page.locator('input[type="email"]')
  const subscribeButton = page.locator('button:has-text("Subscribe")')
  // Fill in email address
  await emailInput.fill('test@newswave.com')
  
  // Click subscribe button
  await subscribeButton.click()
  
  // Wait for success message to appear
  await expect(page.locator('text=Successfully subscribed')).toBeVisible()
  
  // Verify email input is cleared after successful signup
  await expect(emailInput).toHaveValue('')
})
Test article browsing and search functionality:
// e2e/article-browsing.spec.js - Test article discovery features
test('user can browse and search articles', async ({ page }) => {
  await page.goto('http://localhost:3000')
  
  // Click on first article
  await page.click('text=Breaking: Next.js 15 Released')
  
  // Verify navigation to article page
  await expect(page).toHaveURL(/\/articles\//)
  await expect(page.locator('h1')).toContainText('Breaking: Next.js 15 Released')
  
  // Go back to homepage
  await page.goBack()
  // Test search functionality
  await page.fill('input[placeholder="Search articles..."]', 'Next.js')
  await page.click('button:has-text("Search")')
  
  // Wait for search results
  await expect(page.locator('.search-results')).toBeVisible()
  await expect(page.locator('.article-card')).toContainText('Next.js')
})
Terminal
$ npx playwright test
Running 2 tests using 1 worker
✓ user can subscribe to newsletter (2.1s)
✓ user can browse and search articles (1.8s)
2 passed (4.2s)

What just happened?

You created end-to-end tests that simulate real user interactions in a browser. These tests verify complete workflows from homepage to article reading to newsletter signup. Try this: Run the tests and watch Playwright control the browser automatically.

Testing Best Practices

Good tests share common characteristics. They run fast, produce consistent results, and clearly communicate what they test. Bad tests slow development, break randomly, and confuse developers. The testing pyramid guides your testing strategy. Write many unit tests, fewer integration tests, and just enough E2E tests. Unit tests catch bugs early and run in seconds. E2E tests catch critical workflows but take minutes. Test behavior, not implementation. Users care about clicking buttons and seeing results. They do not care about internal state management or function calls. Focus tests on what users experience.

Common Testing Mistakes

Testing implementation details instead of behavior leads to brittle tests that break when code changes. Testing too many things in one test makes failures hard to debug. Not testing edge cases like empty states or error conditions leaves bugs undiscovered.

Organize tests in a clear structure that mirrors your application structure:
📁 newswave/
📁 __tests__/
📄 setup.js
📁 components/
📄 ArticleCard.test.js
📄 SearchForm.test.js
📁 pages/
📄 homepage.test.js
📁 api/
📄 newsletter.test.js
📁 e2e/
📄 user-journeys.spec.js
Create helper functions that reduce test duplication and improve readability:
// __tests__/helpers/test-utils.js - Reusable testing utilities
import { render } from '@testing-library/react'
import { RouterContext } from 'next/dist/shared/lib/router-context'

// Custom render function with router mock
export function renderWithRouter(component, router = {}) {
  const defaultRouter = {
    route: '/',
    pathname: '/',
    query: {},
    asPath: '/',
    push: jest.fn(),
    ...router
  }
  return render(
    <RouterContext.Provider value={defaultRouter}>
      {component}
    </RouterContext.Provider>
  )
}

// Create mock articles for consistent testing
export const createMockArticle = (overrides = {}) => ({
  id: '1',
  title: 'Test Article Title',
  author: 'Test Author',
  publishedAt: '2024-03-15',
  content: 'Test article content...',
  ...overrides
})
Use the helper functions in your tests to reduce boilerplate code:
// __tests__/components/ArticleList.test.js - Using helper functions
import { screen } from '@testing-library/react'
import { renderWithRouter, createMockArticle } from '../helpers/test-utils'
import ArticleList from '../../components/ArticleList'

test('displays list of articles', () => {
  const articles = [
    createMockArticle({ title: 'First Article' }),
    createMockArticle({ title: 'Second Article', id: '2' })
  ]
  
  renderWithRouter(<ArticleList articles={articles} />)
  
  expect(screen.getByText('First Article')).toBeInTheDocument()
  expect(screen.getByText('Second Article')).toBeInTheDocument()
})

What just happened?

You created reusable test utilities that eliminate duplicate code and make tests more maintainable. Helper functions handle common setup like router mocking and test data creation. Try this: Use these helpers in all your component tests.

Run tests automatically in your CI/CD pipeline to catch bugs before deployment. Configure test coverage to ensure critical code paths have tests. Aim for high coverage on business logic, lower coverage on boilerplate code.
// package.json - Scripts for different testing scenarios
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch", 
    "test:coverage": "jest --coverage --watchAll=false",
    "test:ci": "jest --ci --coverage --watchAll=false",
    "test:e2e": "playwright test",
    "test:all": "npm run test:ci && npm run test:e2e"
  }
}
Testing transforms your development process. You catch bugs immediately instead of days later. Refactoring becomes safe because tests verify behavior stays correct. New team members understand code better through comprehensive test examples. Your NewsWave site now has robust tests covering components, API routes, pages, and user workflows. Users receive a polished experience because you caught bugs before deployment. The testing foundation supports confident development and reliable releases.

Quiz

1. The NewsWave team wants to test their article search component. What should they focus on testing?


2. When testing NewsWave's newsletter API route, what tool helps create mock HTTP requests and responses?


3. What does the testing pyramid recommend for NewsWave's testing strategy?


Up Next: Security in Next.js

Protect your Next.js applications from common security vulnerabilities and implement authentication best practices.