Switch Language
Toggle Theme

Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup

It was Monday morning at 10 AM. Coffee still in hand, our tech lead dropped a message in the group chat: “Starting this week, we’re adding unit tests to the project. Next.js with Jest.” I stared at the screen, fingers hovering over the keyboard—honestly, my mind went blank. I’d written React tests before, but this whole App Router and Server Components thing in Next.js? No clue how to test that.

I opened the project, confidently typed npm install jest, and ran npm test. Then the screen erupted with red errors:

Error: Cannot use import statement outside a module
SyntaxError: Unexpected token 'export'
Cannot find module 'next/navigation'

For two solid days, I bounced between config files, Stack Overflow, and GitHub Issues. Tried dozens of configuration approaches, edited jest.config.js countless times. When the tests finally passed, I almost threw my keyboard in celebration.

Honestly, configuring Next.js testing environment was way more complex than I imagined. But it’s not as mystical as online posts make it seem—once you grasp a few core configurations and avoid common pitfalls, you can get it running in ten minutes. This article will walk you through setting up Next.js 15 + Jest + React Testing Library from scratch, sharing the traps I fell into and how to test Client Components, Server Components, Hooks, and API Mocks.

If you’re also struggling with test configuration, keep reading.

Testing Environment Setup (From Zero to One)

First things first, let’s get the environment running. This part might seem dry, but I’ll explain why each configuration is needed so you’re not left confused after copying code.

Installing Dependencies

Open the terminal and install these packages all at once:

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node @types/jest

Quick explanation of each package:

  • jest: The testing framework itself
  • jest-environment-jsdom: Simulates browser environment (React components need DOM)
  • @testing-library/react: React component testing utilities
  • @testing-library/jest-dom: Extra assertion methods (like toBeInTheDocument())
  • ts-node and @types/jest: TypeScript support (skip if you’re using JS)

After installing, don’t rush to run tests yet—config files aren’t written.

Creating jest.config.ts

This is the most critical config file. Create jest.config.ts in your project root (.js suffix if you’re using JavaScript):

import type { Config } from 'jest'
import nextJest from 'next/jest'

// This function automatically loads Next.js configuration
const createJestConfig = nextJest({
  dir: './', // Next.js project root directory
})

const config: Config = {
  coverageProvider: 'v8', // Code coverage tool
  testEnvironment: 'jsdom', // Simulate browser environment
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], // Config file before tests run
}

// Wrap config with createJestConfig to auto-handle Next.js transformations
export default createJestConfig(config)

Here’s the key: Why wrap the config with next/jest?

next/jest automatically handles these for you:

  • Processes .css, .module.css files (auto-mocks them, otherwise tests will error)
  • Handles images, fonts, and other static assets
  • Loads .env environment variables
  • Transforms TypeScript and JSX
  • Excludes node_modules and .next directories

Without it, you’d have to manually configure all this stuff. Trust me, that’s painful.

Creating jest.setup.ts

Create another file jest.setup.ts in the root directory. Content is super simple:

import '@testing-library/jest-dom'

This single line imports Jest DOM’s custom matchers, letting you use assertions like:

  • expect(element).toBeInTheDocument()
  • expect(element).toHaveClass('active')
  • expect(element).toBeVisible()

Without this file, none of these methods will be recognized.

Configuring Path Aliases (if you use @/ paths)

If your project uses path aliases like import Button from '@/components/Button', you need to tell Jest how to resolve these paths.

Check if your tsconfig.json (or jsconfig.json) has this configuration:

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/components/*": ["components/*"],
      "@/lib/*": ["lib/*"]
    }
  }
}

Then add moduleNameMapper to jest.config.ts:

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  // Add this section
  moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/lib/(.*)$': '<rootDir>/lib/$1',
  },
}

Why do this? Jest doesn’t recognize @/ paths by default—it only understands relative or absolute paths. You need to tell it: “When you see @/components/Button, go find the file at <rootDir>/components/Button.”

Adding Test Scripts

Finally, add two scripts to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}
  • npm test: Run all tests once
  • npm test:watch: Watch mode, automatically re-run tests when files change

Verifying Configuration

Let’s run a simple test to verify the setup. Create __tests__/example.test.ts in your project:

describe('Example Test', () => {
  it('should pass', () => {
    expect(1 + 1).toBe(2)
  })
})

Run npm test. If you see green PASS and 1 passed, congratulations—configuration succeeded!

If it errors, don’t panic. Check Chapter 5 for common issue troubleshooting—90% of errors have solutions there.

Component Testing in Practice

Configuration done, now let’s write some real tests. Component testing is the core of Next.js testing, but Client Components and Server Components test completely differently.

Client Components Testing (Standard Process)

Client Components are those familiar ones with 'use client'—testing them is straightforward.

Suppose you have a login form component LoginForm.tsx:

'use client'

import { useState } from 'react'

export default function LoginForm() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!email.includes('@')) {
      setError('Please enter a valid email')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      {error && <span role="alert">{error}</span>}
      <button type="submit">Login</button>
    </form>
  )
}

Test file LoginForm.test.tsx:

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

describe('LoginForm', () => {
  it('should render input and button', () => {
    render(<LoginForm />)

    // Check if input exists
    const emailInput = screen.getByPlaceholderText('Email')
    expect(emailInput).toBeInTheDocument()

    // Check if button exists
    const submitButton = screen.getByRole('button', { name: 'Login' })
    expect(submitButton).toBeInTheDocument()
  })

  it('should show error for invalid email', () => {
    render(<LoginForm />)

    const emailInput = screen.getByPlaceholderText('Email')
    const submitButton = screen.getByRole('button', { name: 'Login' })

    // Simulate user input
    fireEvent.change(emailInput, { target: { value: 'invalid-email' } })
    fireEvent.click(submitButton)

    // Check error message
    const errorMessage = screen.getByRole('alert')
    expect(errorMessage).toHaveTextContent('Please enter a valid email')
  })
})

Testing approach:

  1. Use render() to render the component
  2. Use screen.getByXxx() to find elements (by role, text, placeholder, etc.)
  3. Use fireEvent to simulate user interactions
  4. Use expect() to make assertions

Pro tip here: Prefer getByRole over getByTestId. Because roles (like button, alert) are closer to what users actually see, making tests more stable.

Server Components Testing (A Bit Awkward)

Server Components are Next.js 15’s core feature, but honestly, Jest doesn’t support them well.

Core problem: Jest doesn’t support async Server Components.

For example, you have a component that fetches data from a database:

// app/posts/page.tsx (Server Component)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

If you test this component directly, Jest will error: Objects are not valid as a React child.

So what to do?

Three approaches:

Approach 1: Extract business logic, test pure functions

// lib/posts.ts
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

// lib/posts.test.ts
import { getPosts } from './posts'

describe('getPosts', () => {
  it('should fetch posts successfully', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve([{ id: 1, title: 'Test Post' }]),
      })
    ) as jest.Mock

    const posts = await getPosts()
    expect(posts).toHaveLength(1)
    expect(posts[0].title).toBe('Test Post')
  })
})

This way you’re testing data fetching logic, not the component itself. Basically, extract complex async logic separately, leaving the component with just simple rendering.

Approach 2: Test synchronous Server Components

If the Server Component doesn’t involve async, you can test normally:

// components/Title.tsx (Server Component, no async)
export default function Title({ text }: { text: string }) {
  return <h1 className="title">{text}</h1>
}

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

describe('Title', () => {
  it('should render title', () => {
    render(<Title text="Hello World" />)
    const heading = screen.getByRole('heading', { level: 1 })
    expect(heading).toHaveTextContent('Hello World')
  })
})

Approach 3: Supplement with E2E testing

For complex Server Components, honestly, using Playwright or Cypress for E2E testing is more reliable. Jest unit tests handle frontend logic, E2E tests handle complete workflows—each to their own.

My approach: Extract core business logic into pure functions and test them separately. Server Components only do simple rendering, leaving full coverage to E2E tests.

Interaction Testing Tips

When testing user interactions, @testing-library/react provides many methods:

import { render, screen, fireEvent, waitFor } from '@testing-library/react'

// Click
fireEvent.click(button)

// Input
fireEvent.change(input, { target: { value: 'test' } })

// Wait for async updates
await waitFor(() => {
  expect(screen.getByText('Success')).toBeInTheDocument()
})

// Check if element is visible
expect(element).toBeVisible()

// Check class name
expect(element).toHaveClass('active')

Complete async interaction test example:

it('should submit form successfully', async () => {
  // Mock API
  global.fetch = jest.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ success: true }),
    })
  ) as jest.Mock

  render(<LoginForm />)

  const emailInput = screen.getByPlaceholderText('Email')
  const submitButton = screen.getByRole('button', { name: 'Login' })

  fireEvent.change(emailInput, { target: { value: '[email protected]' } })
  fireEvent.click(submitButton)

  // Wait for success message to appear
  await waitFor(() => {
    expect(screen.getByText('Login successful')).toBeInTheDocument()
  })
})

Note waitFor—it waits for async operations to complete before checking. If your component has useEffect or async state updates, you must use it. Otherwise tests will execute assertions before state updates, causing false negatives.

Hook Testing Strategies

Custom Hooks are React’s essence, but how do you test them? Some test them directly in components, but that mixes Hook logic with component logic. Better approach: use renderHook.

Testing Simple Hooks

Say you wrote a counter Hook:

// hooks/useCounter.ts
import { useState } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)

  return { count, increment, decrement, reset }
}

Testing it:

// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('should reset count', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.increment()
      result.current.increment()
    })

    expect(result.current.count).toBe(7)

    act(() => {
      result.current.reset()
    })

    expect(result.current.count).toBe(5)
  })
})

Key points:

  • renderHook renders the Hook
  • act wraps state update operations (React’s rule, ensures state updates before assertions)
  • result.current gets the Hook’s return value

Testing Context-Dependent Hooks

If your Hook depends on Context (like Auth Context), you need to provide a Provider.

// hooks/useAuth.ts
import { useContext } from 'react'
import { AuthContext } from '@/contexts/AuthContext'

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

Testing with Mock Provider:

// hooks/useAuth.test.tsx
import { renderHook } from '@testing-library/react'
import { useAuth } from './useAuth'
import { AuthContext } from '@/contexts/AuthContext'

describe('useAuth', () => {
  it('should return auth context value', () => {
    const mockAuthValue = {
      user: { id: 1, name: 'Test User' },
      login: jest.fn(),
      logout: jest.fn(),
    }

    const wrapper = ({ children }: { children: React.ReactNode }) => (
      <AuthContext.Provider value={mockAuthValue}>
        {children}
      </AuthContext.Provider>
    )

    const { result } = renderHook(() => useAuth(), { wrapper })

    expect(result.current.user).toEqual({ id: 1, name: 'Test User' })
    expect(result.current.login).toBeDefined()
  })

  it('should throw error when used outside provider', () => {
    // Capture error
    const { result } = renderHook(() => useAuth())
    expect(result.error).toEqual(
      Error('useAuth must be used within AuthProvider')
    )
  })
})

Technique: Use wrapper parameter to wrap Provider, so Hook can access Context.

Testing Async Hooks (Data Fetching)

Many Hooks nowadays fetch data, like:

// hooks/useFetch.ts
import { useState, useEffect } from 'react'

export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        const response = await fetch(url)
        if (!response.ok) throw new Error('Network error')
        const json = await response.json()
        setData(json)
      } catch (err) {
        setError(err as Error)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [url])

  return { data, loading, error }
}

Testing async Hooks requires mocking fetch and waiting for state updates:

// hooks/useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'

describe('useFetch', () => {
  beforeEach(() => {
    // Reset fetch Mock before each test
    jest.resetAllMocks()
  })

  it('should fetch data successfully', async () => {
    const mockData = { id: 1, title: 'Test' }

    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockData),
      })
    ) as jest.Mock

    const { result } = renderHook(() => useFetch('/api/data'))

    // Initial state: loading = true
    expect(result.current.loading).toBe(true)
    expect(result.current.data).toBeNull()

    // Wait for data to load
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.data).toEqual(mockData)
    expect(result.current.error).toBeNull()
  })

  it('should handle fetch error', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: false,
      })
    ) as jest.Mock

    const { result } = renderHook(() => useFetch('/api/data'))

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.error).toBeTruthy()
    expect(result.current.error?.message).toBe('Network error')
    expect(result.current.data).toBeNull()
  })
})

Key notes:

  • Use waitFor to wait for async operations to complete
  • Reset Mocks in beforeEach to avoid tests interfering with each other
  • Test both success and failure scenarios

Hook testing core: mock dependencies, trigger state changes, assert results. Master these three steps, any Hook can be tested.

Mock Techniques Encyclopedia

Mocking is testing’s soul. Without mocking, tests depend on real APIs, databases, third-party services—slow and unstable. Next.js has plenty of special things that need mocking. This chapter covers how to handle them.

Mocking Next.js Router (Most Common)

Next.js router Hooks (useRouter, usePathname, useSearchParams) aren’t available in test environments by default—must be mocked.

Mocking useRouter (App Router):

// __mocks__/next/navigation.ts
export const useRouter = jest.fn()
export const usePathname = jest.fn()
export const useSearchParams = jest.fn()

Using in test file:

import { useRouter } from 'next/navigation'

// Mock router behavior
jest.mock('next/navigation', () => ({
  useRouter: jest.fn(),
  usePathname: jest.fn(),
  useSearchParams: jest.fn(),
}))

describe('NavigationComponent', () => {
  it('should navigate to home on button click', () => {
    const pushMock = jest.fn()
    ;(useRouter as jest.Mock).mockReturnValue({
      push: pushMock,
      back: jest.fn(),
      forward: jest.fn(),
    })

    render(<NavigationComponent />)

    const button = screen.getByRole('button', { name: 'Back to Home' })
    fireEvent.click(button)

    expect(pushMock).toHaveBeenCalledWith('/')
  })
})

Mocking usePathname (getting current path):

import { usePathname } from 'next/navigation'

jest.mock('next/navigation', () => ({
  usePathname: jest.fn(),
}))

describe('HeaderComponent', () => {
  it('should highlight active nav item', () => {
    ;(usePathname as jest.Mock).mockReturnValue('/about')

    render(<Header />)

    const aboutLink = screen.getByRole('link', { name: 'About' })
    expect(aboutLink).toHaveClass('active')
  })
})

Mocking Next.js Image Component

next/image also errors in test environments because it depends on Next.js’s image optimization service.

Approach 1: Mock as regular img tag:

// __mocks__/next/image.tsx
const Image = ({ src, alt }: { src: string; alt: string }) => {
  return <img src={src} alt={alt} />
}

export default Image

Approach 2: Global Mock in jest.config.ts:

const config: Config = {
  // ... other config
  moduleNameMapper: {
    '^next/image$': '<rootDir>/__mocks__/next/image.tsx',
  },
}

This automatically replaces all next/image usage with the mock version.

Mocking API Requests (Three Methods)

Method 1: Mock global fetch (simplest):

global.fetch = jest.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve({ data: 'mock data' }),
  })
) as jest.Mock

Method 2: Use MSW (Mock Service Worker) (more powerful):

npm install -D msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'Test Post' },
    ])
  }),

  http.post('/api/login', async ({ request }) => {
    const { email } = await request.json()
    if (email === '[email protected]') {
      return HttpResponse.json({ success: true })
    }
    return HttpResponse.json({ error: 'Invalid email' }, { status: 400 })
  }),
]
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Start Mock Server in jest.setup.ts:

import '@testing-library/jest-dom'
import { server } from './mocks/server'

// Start Mock Server before tests
beforeAll(() => server.listen())

// Reset handlers after each test
afterEach(() => server.resetHandlers())

// Close Server after tests complete
afterAll(() => server.close())

MSW’s advantage is intercepting all network requests without writing global.fetch in every test.

Method 3: Mock axios (if you use axios):

npm install -D axios-mock-adapter
import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'

const mock = new MockAdapter(axios)

describe('API Test', () => {
  afterEach(() => {
    mock.reset()
  })

  it('should fetch posts', async () => {
    mock.onGet('/api/posts').reply(200, [{ id: 1, title: 'Test' }])

    const response = await axios.get('/api/posts')
    expect(response.data).toHaveLength(1)
  })
})

Mocking Environment Variables

Next.js environment variables also need mocking in tests.

Method 1: Directly set process.env:

describe('Config Test', () => {
  const originalEnv = process.env

  beforeEach(() => {
    jest.resetModules()
    process.env = { ...originalEnv }
  })

  afterEach(() => {
    process.env = originalEnv
  })

  it('should use API URL from env', () => {
    process.env.NEXT_PUBLIC_API_URL = 'https://test-api.com'

    const { getApiUrl } = require('@/lib/config')
    expect(getApiUrl()).toBe('https://test-api.com')
  })
})

Method 2: Use .env.test file:

Create .env.test file, next/jest will auto-load it:

NEXT_PUBLIC_API_URL=https://test-api.com
DATABASE_URL=postgresql://test:test@localhost:5432/test

Mocking Third-Party Modules (Prisma Example)

If you use Prisma for database queries, you definitely don’t want to connect to a real database during testing.

Mock Prisma Client:

// __mocks__/prisma.ts
export const prisma = {
  user: {
    findMany: jest.fn(),
    findUnique: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  },
  post: {
    findMany: jest.fn(),
    create: jest.fn(),
  },
}

Using in tests:

import { prisma } from '@/lib/prisma'

jest.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: jest.fn(),
    },
  },
}))

describe('getUserById', () => {
  it('should return user', async () => {
    const mockUser = { id: 1, name: 'Test User' }
    ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser)

    const result = await getUserById(1)
    expect(result).toEqual(mockUser)
    expect(prisma.user.findUnique).toHaveBeenCalledWith({
      where: { id: 1 },
    })
  })
})

Common Mock Errors and Solutions

Issue 1: Cannot find module 'next/router'

Cause: Next.js router module not mocked.

Solution: Add jest.mock('next/navigation') at test file top.

Issue 2: Mock not working

Cause: jest.mock in wrong position—must be at file top (after imports).

import { useRouter } from 'next/navigation'

// Must mock here
jest.mock('next/navigation')

describe('Test', () => {
  // ...
})

Issue 3: Environment variables not reading

Cause: Tests not loading .env file.

Solution: Ensure jest.config.ts uses next/jest wrapped config—it auto-loads environment variables.

Master these Mock techniques, and basically all Next.js special features can be tested.

Common Issue Troubleshooting

Honestly, errors are the norm when configuring Jest. Good news is, 90% of errors are the same few types with standard solutions.

Error 1: Cannot use import statement outside a module

Full error:

SyntaxError: Cannot use import statement outside a module

Cause: Jest doesn’t support ES Modules by default, but your code or dependencies use import/export.

Solution: Add to jest.config.ts:

const config: Config = {
  // ... other config
  extensionsToTreatAsEsm: ['.ts', '.tsx'],
  transformIgnorePatterns: [
    'node_modules/(?!(module-that-uses-esm)/)',
  ],
}

If issue is with an npm package (like nanoid, uuid), add package name to transformIgnorePatterns exception list:

transformIgnorePatterns: [
  'node_modules/(?!(nanoid|uuid)/)',
]

Error 2: Unexpected token ‘export’

Cause: Similar to above, some file not transformed by Jest.

Solution: Check jest.config.ts transform and transformIgnorePatterns config. Ensure next/jest wraps config:

import nextJest from 'next/jest'

const createJestConfig = nextJest({ dir: './' })

export default createJestConfig(config)

next/jest auto-handles TypeScript and JSX transformation.

Error 3: Cannot find module ’@/components/…’

Cause: Jest doesn’t recognize path alias (@/).

Solution: Configure moduleNameMapper in jest.config.ts to tell Jest where @/ points:

moduleNameMapper: {
  '^@/components/(.*)$': '<rootDir>/components/$1',
  '^@/lib/(.*)$': '<rootDir>/lib/$1',
  '^@/(.*)$': '<rootDir>/$1',
}

Ensure these paths match paths config in tsconfig.json.

Error 4: Objects are not valid as a React child

Cause: Tested async Server Component—Jest doesn’t support it.

Solution:

  1. Extract async logic into pure functions, test separately
  2. Or use E2E testing (Playwright, Cypress)

Don’t force Jest to test Server Components—tools aren’t omnipotent.

Error 5: act(…) warning

Full error:

Warning: An update to Component inside a test was not wrapped in act(...).

Cause: Component has async state updates (like useEffect, setTimeout), test didn’t wait for updates to complete.

Solution: Use waitFor or act to wrap async operations:

import { waitFor } from '@testing-library/react'

it('should update state', async () => {
  render(<Component />)

  await waitFor(() => {
    expect(screen.getByText('Updated')).toBeInTheDocument()
  })
})

Or use act:

import { act } from '@testing-library/react'

it('should trigger callback', async () => {
  await act(async () => {
    render(<Component />)
  })
})

Tests Running Slow? Try These Optimizations

  1. Run tests in parallel:
{
  "scripts": {
    "test": "jest --maxWorkers=4"
  }
}
  1. Only test changed files:
npm test -- --onlyChanged
  1. Disable code coverage (when debugging):
{
  "scripts": {
    "test:fast": "jest --no-coverage"
  }
}
  1. Skip slow tests with test.skip:
describe.skip('Slow Tests', () => {
  // These tests will be skipped
})

Diagnostic Checklist

When encountering errors, check in this order:

  1. ✅ Does jest.config.ts use next/jest wrapped config?
  2. ✅ Does jest.setup.ts correctly import @testing-library/jest-dom?
  3. ✅ Does path alias config match tsconfig.json?
  4. ✅ Are needed modules (next/navigation, next/image) mocked?
  5. ✅ Do async operations use waitFor or act?
  6. ✅ Are dependency versions compatible (especially React 19 and Jest)?

Going through this checklist can solve most issues.

Conclusion

Configuring test environments is indeed tedious, but once running, it really elevates code quality.

When I first started writing tests, I also thought it was time-wasting—5 minutes to write a feature, 10 minutes to write tests. But later I found that with tests, refactoring code and fixing bugs felt much more secure. Run tests after changing code—all green means safe, any red immediately shows what broke.

My advice to you:

  • Don’t wait until the project is huge to start writing tests. Starting now, add a test for every new feature—make it a habit.
  • Don’t chase 100% coverage. Core business logic, bug-prone areas—cover those well with tests.
  • If Server Components can’t be tested, don’t force it. Extract logic or supplement with E2E tests.
  • When encountering errors, don’t panic. Follow Chapter 5’s checklist for troubleshooting—most likely you’ll solve it.

Save the config files from this article. Next project, just copy—get test environment running in ten minutes. Write tests, reduce bugs, code quality naturally improves.

If you encounter issues configuring or writing tests, feel free to leave comments. I’ve been through the struggle myself—happy to help where I can.

Complete Next.js Jest Testing Environment Setup Process

Detailed steps to configure Next.js 15 + Jest + React Testing Library testing environment from scratch

⏱️ Estimated time: 15 min

  1. 1

    Step1: Install testing dependency packages

    Install Jest and React Testing Library suite:

    • npm install -D jest jest-environment-jsdom
    • npm install -D @testing-library/react @testing-library/dom @testing-library/jest-dom
    • npm install -D ts-node @types/jest (for TypeScript projects)

    Package descriptions:
    • jest: Testing framework core
    • jest-environment-jsdom: Simulates browser DOM environment
    • @testing-library/react: React component testing utilities
    • @testing-library/jest-dom: Extended assertion methods (like toBeInTheDocument)

    After installing everything at once, don't run tests immediately—finish creating config files first.
  2. 2

    Step2: Create Jest config file

    Create jest.config.ts in project root, use next/jest to auto-handle Next.js features:

    ```typescript
    import type { Config } from 'jest'
    import nextJest from 'next/jest'

    const createJestConfig = nextJest({ dir: './' })

    const config: Config = {
    coverageProvider: 'v8',
    testEnvironment: 'jsdom',
    setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
    // If using path aliases, add moduleNameMapper
    moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/lib/(.*)$': '<rootDir>/lib/$1',
    },
    }

    export default createJestConfig(config)
    ```

    next/jest auto-handles: CSS/image mocking, environment variable loading, TypeScript transformation, node_modules exclusion.
  3. 3

    Step3: Create Jest startup config

    Create jest.setup.ts in project root, import Jest DOM extensions:

    ```typescript
    import '@testing-library/jest-dom'
    ```

    This single line lets you use extended assertion methods:
    • expect(element).toBeInTheDocument()
    • expect(element).toHaveClass('active')
    • expect(element).toBeVisible()
    • expect(element).toHaveTextContent('text')

    Without importing this file, all above methods will throw "not a function" errors.
  4. 4

    Step4: Configure test scripts

    Add test commands to package.json:

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

    Three command purposes:
    • npm test: Run all tests (CI/CD use)
    • npm run test:watch: Watch mode, auto-test on file changes (development use)
    • npm run test:coverage: Generate code coverage report
  5. 5

    Step5: Create example test to verify config

    Create __tests__/example.test.ts to verify environment setup:

    ```typescript
    describe('Example Test', () => {
    it('should pass basic assertion', () => {
    expect(1 + 1).toBe(2)
    })
    })
    ```

    Run npm test, seeing green PASS means config succeeded.

    If errors occur, check in order:
    1. Does jest.config.ts use createJestConfig wrapper
    2. Is jest.setup.ts correctly imported
    3. Are package.json test scripts correct
    4. Does path alias config match tsconfig.json

FAQ

Why must next/jest wrap the configuration?
next/jest is Next.js's official Jest configuration tool that automatically handles many complex configurations:

• Auto-mocks CSS and image files (prevents test errors)
• Auto-loads .env environment variables
• Auto-transforms TypeScript and JSX
• Auto-excludes node_modules and .next directories
• Configures Next.js Compiler transformation rules

Without next/jest, you'd need to manually configure all the above—very tedious and error-prone. Official strongly recommends using createJestConfig to wrap configuration.
Can Server Components be tested with Jest?
Partially yes, but with limitations:

• Synchronous Server Components: Can test normally
• async Server Components: Jest doesn't support, will error with "Objects are not valid as a React child"

Recommended approach:
1. Extract async logic into pure functions, test data fetching logic separately
2. Server Components only do simple rendering, no complex logic
3. Use Playwright or Cypress for E2E tests covering complete workflows

Don't force Jest to test all Server Components—choosing the right tool matters more.
How to mock Next.js useRouter in tests?
Next.js App Router's router Hooks must be mocked for testing:

```typescript
import { useRouter } from 'next/navigation'

jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
usePathname: jest.fn(),
useSearchParams: jest.fn(),
}))

// Set return value in test
const pushMock = jest.fn()
;(useRouter as jest.Mock).mockReturnValue({
push: pushMock,
back: jest.fn(),
forward: jest.fn(),
})
```

Note jest.mock must be at test file top (after imports), can't be inside describe or it.
Why the 'Cannot use import statement outside a module' error?
This is caused by Jest not supporting ES Modules by default. Two solutions:

Solution 1: Configure transformIgnorePatterns (recommended)
```typescript
// jest.config.ts
const config: Config = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transformIgnorePatterns: [
'node_modules/(?!(nanoid|uuid)/)', // List packages using ESM
],
}
```

Solution 2: Ensure next/jest correctly wraps config
```typescript
import nextJest from 'next/jest'
const createJestConfig = nextJest({ dir: './' })
export default createJestConfig(config)
```

90% of cases are some npm package using ESM—add package name to transformIgnorePatterns exception list.
How to mock API requests? Which method is recommended?
Three common methods, increasing in complexity:

Method 1: Mock global fetch (simplest, suitable for small projects)
```typescript
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
})
) as jest.Mock
```

Method 2: MSW (Mock Service Worker, recommended, suitable for medium-large projects)
• Can intercept all network requests
• Supports complex request/response logic
• No need to write Mock in every test
• Install: npm install -D msw

Method 3: axios-mock-adapter (if project uses axios)
• Mock library specifically for axios
• Clean API, easy to use

Recommend using MSW because it's closer to real network requests, highly reusable, suitable for team collaboration.
Tests running slow—how to optimize?
Four practical optimization methods:

1. Run tests in parallel (most effective)
```json
{ "scripts": { "test": "jest --maxWorkers=4" } }
```

2. Only test changed files
```bash
npm test -- --onlyChanged
```

3. Disable code coverage (when debugging)
```json
{ "scripts": { "test:fast": "jest --no-coverage" } }
```

4. Skip slow tests
```typescript
describe.skip('Slow E2E Tests', () => {
// These tests will be skipped
})
```

Recommend using --onlyChanged and --no-coverage during development, run complete tests in CI/CD.
Path alias @/ configured but tests still error—what to do?
Ensure jest.config.ts and tsconfig.json path configs are consistent:

tsconfig.json (or jsconfig.json):
```json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"]
}
}
}
```

jest.config.ts:
```typescript
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/lib/(.*)$': '<rootDir>/lib/$1',
}
```

Note two points:
1. tsconfig uses relative paths (without <rootDir>)
2. jest.config uses absolute paths (with <rootDir>)
3. Wildcard syntax must be consistent (.*)

After modifying, restart tests—should recognize path aliases now.

11 min read · Published on: Jan 7, 2026 · Modified on: Jan 15, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts