切换语言
切换主题

Next.js 单元测试实战:Jest + React Testing Library 完整配置指南

周一早上十点,咖啡还没喝完,技术Leader在群里扔了一句:“这周开始给项目加单元测试,Next.js 用 Jest。“我盯着屏幕,手指悬在键盘上——说实话,当时脑子里一片空白。之前写过 React 测试,但 Next.js 这套 App Router、Server Components,完全不知道怎么测。

打开项目,敲下 npm install jest,信心满满地运行 npm test。然后,屏幕上爆出一堆红色错误:

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

整整两天,我在配置文件、Stack Overflow 和 GitHub Issues 之间反复横跳。试了十几种配置方案,改了无数次 jest.config.js,测试终于跑通的那一刻,我差点把键盘扔出去庆祝。

老实讲,Next.js 测试环境的配置比我想象的复杂太多。但也没网上说的那么玄乎——只要搞清楚几个核心配置、避开常见的坑,其实十分钟就能跑起来。这篇文章,我会带你从零配置 Next.js 15 + Jest + React Testing Library,顺便分享我踩过的那些坑,以及怎么测试 Client Components、Server Components、Hooks 和 API Mock。

如果你也在为测试配置头疼,接着往下看。

测试环境配置(从零到一)

别的不说,先把环境跑起来。这部分看起来枯燥,但每个配置项我都会解释为什么需要,避免你copy代码后还是一脸懵。

安装依赖

打开终端,一口气装完这些包:

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

快速解释一下每个包是干嘛的:

  • jest:测试框架本体
  • jest-environment-jsdom:模拟浏览器环境(React 组件需要 DOM)
  • @testing-library/react:React 组件测试工具
  • @testing-library/jest-dom:额外的断言方法(比如 toBeInTheDocument()
  • ts-node@types/jest:TypeScript 支持(如果你用 JS 可以跳过)

装完之后,别急着跑测试,配置文件还没写呢。

创建 jest.config.ts

这是最核心的配置文件。在项目根目录新建 jest.config.ts(用 JS 的话就是 .js 后缀):

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

// 这个函数会自动加载 Next.js 配置
const createJestConfig = nextJest({
  dir: './', // Next.js 项目根目录
})

const config: Config = {
  coverageProvider: 'v8', // 代码覆盖率工具
  testEnvironment: 'jsdom', // 模拟浏览器环境
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], // 测试启动前的配置文件
}

// 用 createJestConfig 包裹配置,自动处理 Next.js 的各种转换
export default createJestConfig(config)

重点来了:为什么要用 next/jest 包裹配置?

next/jest 会自动帮你做这些事:

  • 处理 .css.module.css 文件(自动 Mock,不然测试会报错)
  • 处理图片、字体等静态资源
  • 加载 .env 环境变量
  • 转换 TypeScript 和 JSX
  • 排除 node_modules.next 目录

如果不用它,你得手动配置这一堆东西。相信我,那会很痛苦。

创建 jest.setup.ts

在根目录再创建一个 jest.setup.ts 文件,内容超简单:

import '@testing-library/jest-dom'

这一行代码会导入 Jest DOM 的自定义匹配器,让你可以用这些断言:

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

没这个文件,上面这些方法都不认识。

配置路径别名(如果你用了 @/ 这种路径)

如果你的项目用了路径别名,比如 import Button from '@/components/Button',需要告诉 Jest 怎么解析这些路径。

先看你的 tsconfig.json(或 jsconfig.json)里是不是有这样的配置:

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

然后在 jest.config.ts 里加上 moduleNameMapper

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

为什么要这么做? Jest 默认不认识 @/ 这种路径,它只认识相对路径或绝对路径。你得告诉它:“看到 @/components/Button,就去 <rootDir>/components/Button 找文件。“

添加测试脚本

最后,在 package.json 里加两个脚本:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}
  • npm test:跑一次所有测试
  • npm test:watch:监听模式,文件改动时自动重新测试

验证配置

来,跑个简单测试验证配置是否OK。在项目里创建 __tests__/example.test.ts

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

运行 npm test。如果看到绿色的 PASS1 passed,恭喜,配置成功了。

如果报错,别慌,先看第五章的常见问题排查,90% 的错误都在那里有解决方案。

组件测试实战

配置搞定了,现在该写点真正的测试了。组件测试是 Next.js 测试的核心,但 Client Components 和 Server Components 的测试方式完全不同。

Client Components 测试(标准流程)

Client Components 就是你熟悉的那种加了 'use client' 的组件,测试起来很直接。

假设你有个登录表单组件 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('请输入有效的邮箱')
    }
  }

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

测试文件 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 />)

    // 检查输入框是否存在
    const emailInput = screen.getByPlaceholderText('邮箱')
    expect(emailInput).toBeInTheDocument()

    // 检查按钮是否存在
    const submitButton = screen.getByRole('button', { name: '登录' })
    expect(submitButton).toBeInTheDocument()
  })

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

    const emailInput = screen.getByPlaceholderText('邮箱')
    const submitButton = screen.getByRole('button', { name: '登录' })

    // 模拟用户输入
    fireEvent.change(emailInput, { target: { value: 'invalid-email' } })
    fireEvent.click(submitButton)

    // 检查错误提示
    const errorMessage = screen.getByRole('alert')
    expect(errorMessage).toHaveTextContent('请输入有效的邮箱')
  })
})

测试思路

  1. render() 渲染组件
  2. screen.getByXxx() 找到元素(按角色、文本、占位符等)
  3. fireEvent 模拟用户操作
  4. expect() 断言结果

这里有个小技巧:尽量用 getByRole 而不是 getByTestId。因为 role(比如 buttonalert)更贴近用户实际看到的东西,测试也更稳定。

Server Components 测试(有点尴尬)

Server Components 是 Next.js 15 的核心特性,但说实话,Jest 对它支持不太友好。

核心问题:Jest 不支持 async Server Components。

举个例子,你有个从数据库获取数据的组件:

// 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>
  )
}

如果你直接测试这个组件,Jest 会报错:Objects are not valid as a React child

那怎么办?

三个思路:

方案一:抽离业务逻辑,测试纯函数

// 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')
  })
})

这样你测的是数据获取逻辑,而不是组件本身。说白了,把复杂的异步逻辑抽出来单独测,组件就只剩下简单的渲染。

方案二:测试同步的 Server Components

如果 Server Component 不涉及异步,可以正常测试:

// components/Title.tsx (Server Component, 无 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')
  })
})

方案三:用 E2E 测试补充

对于复杂的 Server Components,老实讲,用 Playwright 或 Cypress 做 E2E 测试更靠谱。Jest 单元测试管前端逻辑,E2E 测试管完整流程,各司其职。

我自己的做法是:核心业务逻辑抽成纯函数单独测,Server Components 只做简单渲染,留给 E2E 测试覆盖。

交互测试技巧

测试用户交互时,@testing-library/react 提供了很多方法:

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

// 点击
fireEvent.click(button)

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

// 等待异步更新
await waitFor(() => {
  expect(screen.getByText('Success')).toBeInTheDocument()
})

// 检查元素是否可见
expect(element).toBeVisible()

// 检查类名
expect(element).toHaveClass('active')

一个完整的异步交互测试示例

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('邮箱')
  const submitButton = screen.getByRole('button', { name: '登录' })

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

  // 等待成功提示出现
  await waitFor(() => {
    expect(screen.getByText('登录成功')).toBeInTheDocument()
  })
})

注意 waitFor,它会等待异步操作完成再检查。如果你的组件有 useEffect 或者异步状态更新,一定要用它,不然测试会在状态更新前就执行断言,导致误报。

Hook 测试策略

自定义 Hook 是 React 的精华,但怎么测试它们?有人直接在组件里测,但那样会把 Hook 逻辑和组件逻辑混在一起。更好的办法是用 renderHook

测试简单的 Hook

假设你写了个计数器 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 }
}

测试它:

// 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)
  })
})

关键点

  • renderHook 用来渲染 Hook
  • act 包裹状态更新操作(React 的规矩,确保状态更新完再断言)
  • result.current 获取 Hook 返回的值

测试依赖 Context 的 Hook

如果你的 Hook 依赖 Context(比如 Auth Context),需要提供 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
}

测试时提供 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', () => {
    // 捕获错误
    const { result } = renderHook(() => useAuth())
    expect(result.error).toEqual(
      Error('useAuth must be used within AuthProvider')
    )
  })
})

技巧:用 wrapper 参数包裹 Provider,这样 Hook 就能访问 Context 了。

测试异步 Hook(数据获取)

现在很多 Hook 用来获取数据,比如:

// 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 }
}

测试异步 Hook 需要 Mock fetch 和等待状态更新:

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

describe('useFetch', () => {
  beforeEach(() => {
    // 每个测试前重置 fetch Mock
    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'))

    // 初始状态:loading = true
    expect(result.current.loading).toBe(true)
    expect(result.current.data).toBeNull()

    // 等待数据加载完成
    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()
  })
})

注意点

  • waitFor 等待异步操作完成
  • beforeEach 里重置 Mock,避免测试相互影响
  • 测试成功和失败两种情况

Hook 测试的核心就是:模拟依赖、触发状态变化、断言结果。掌握这三步,任何 Hook 都能测。

Mock 技巧大全

Mock 是测试的灵魂。不 Mock 的话,测试会依赖真实的 API、数据库、第三方服务,速度慢还不稳定。Next.js 有不少特殊的东西需要 Mock,这章专门讲怎么搞定它们。

Mock Next.js 路由(最常用)

Next.js 的路由 Hooks(useRouterusePathnameuseSearchParams)在测试环境里默认不可用,必须 Mock。

Mock useRouter(App Router)

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

在测试文件里使用:

import { useRouter } from 'next/navigation'

// Mock 路由行为
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: '回到首页' })
    fireEvent.click(button)

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

Mock usePathname(获取当前路径)

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: '关于' })
    expect(aboutLink).toHaveClass('active')
  })
})

Mock Next.js Image 组件

next/image 在测试环境也会报错,因为它依赖 Next.js 的图片优化服务。

方案一:Mock 成普通 img 标签

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

export default Image

方案二:在 jest.config.ts 里全局 Mock

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

这样所有用到 next/image 的地方都会自动替换成 mock 版本。

Mock API 请求(三种方法)

方法一:Mock 全局 fetch(最简单):

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

方法二:用 MSW(Mock Service Worker)(更强大):

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)

jest.setup.ts 里启动 Mock Server:

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

// 测试前启动 Mock Server
beforeAll(() => server.listen())

// 每个测试后重置 handlers
afterEach(() => server.resetHandlers())

// 测试完毕后关闭 Server
afterAll(() => server.close())

MSW 的好处是可以拦截所有网络请求,不用每个测试都写 global.fetch

方法三:Mock axios(如果你用 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)
  })
})

Mock 环境变量

Next.js 的环境变量在测试里也需要 Mock。

方法一:直接设置 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')
  })
})

方法二:用 .env.test 文件

创建 .env.test 文件,next/jest 会自动加载它:

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

Mock 第三方模块(以 Prisma 为例)

如果你用 Prisma 做数据库查询,测试时肯定不想连真实数据库。

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(),
  },
}

在测试里使用:

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 },
    })
  })
})

常见 Mock 错误及解决方案

问题 1Cannot find module 'next/router'

原因:没有 Mock Next.js 路由模块。

解决:在测试文件开头加上 jest.mock('next/navigation')

问题 2:Mock 不生效

原因jest.mock 的位置不对,必须放在文件顶部(在 import 之后)。

import { useRouter } from 'next/navigation'

// 必须在这里 Mock
jest.mock('next/navigation')

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

问题 3:环境变量读取不到

原因:测试没加载 .env 文件。

解决:确保 jest.config.ts 用了 next/jest 包裹配置,它会自动加载环境变量。

掌握这些 Mock 技巧,基本上 Next.js 的特殊功能都能测试了。

常见问题排查

说实话,配置 Jest 的过程中,报错是常态。但好消息是,90% 的错误都是那么几种,有标准解决方案。

错误 1:Cannot use import statement outside a module

完整报错

SyntaxError: Cannot use import statement outside a module

原因:Jest 默认不支持 ES Modules,而你的代码或依赖用了 import/export

解决方案:在 jest.config.ts 里添加:

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

如果问题出在某个 npm 包(比如 nanoiduuid),把包名加到 transformIgnorePatterns 的例外列表里:

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

错误 2:Unexpected token ‘export’

原因:类似上面,某个文件没有被 Jest 转换。

解决方案:检查 jest.config.tstransformtransformIgnorePatterns 配置。确保 next/jest 包裹了配置:

import nextJest from 'next/jest'

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

export default createJestConfig(config)

next/jest 会自动处理 TypeScript 和 JSX 转换。

错误 3:Cannot find module ’@/components/…’

原因:Jest 不认识路径别名(@/)。

解决方案:在 jest.config.ts 里配置 moduleNameMapper,让 Jest 知道 @/ 指向哪里:

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

确保这些路径和 tsconfig.json 里的 paths 配置一致。

错误 4:Objects are not valid as a React child

原因:测试了 async Server Component,Jest 不支持。

解决方案

  1. 把异步逻辑抽离成纯函数,单独测试
  2. 或者用 E2E 测试(Playwright、Cypress)

别强求用 Jest 测试 Server Components,工具不是万能的。

错误 5:act(…) warning

完整报错

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

原因:组件里有异步状态更新(比如 useEffectsetTimeout),测试没有等待更新完成。

解决方案:用 waitForact 包裹异步操作:

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

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

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

或者用 act

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

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

测试运行慢?试试这些优化

  1. 并行运行测试
{
  "scripts": {
    "test": "jest --maxWorkers=4"
  }
}
  1. 只测试改动的文件
npm test -- --onlyChanged
  1. 禁用代码覆盖率(调试时):
{
  "scripts": {
    "test:fast": "jest --no-coverage"
  }
}
  1. test.skip 跳过慢的测试
describe.skip('Slow Tests', () => {
  // 这些测试会被跳过
})

问题诊断清单

遇到报错时,按这个顺序检查:

  1. jest.config.ts 是否用了 next/jest 包裹配置?
  2. jest.setup.ts 是否正确导入 @testing-library/jest-dom
  3. ✅ 路径别名配置是否和 tsconfig.json 一致?
  4. ✅ 需要 Mock 的模块(next/navigationnext/image)是否已 Mock?
  5. ✅ 异步操作是否用了 waitForact
  6. ✅ 依赖版本是否兼容(特别是 React 19 和 Jest)?

按这个清单走一遍,基本能解决大部分问题。

结论

配置测试环境确实麻烦,但跑起来之后,真的会让代码质量上一个台阶。

刚开始写测试的时候,我也觉得浪费时间——写个功能5分钟,写测试要10分钟。但后来发现,有了测试之后,重构代码、修Bug的时候心里踏实多了。改完代码跑一遍测试,全绿就放心,哪里红了就知道改坏了什么。

给你的建议

  • 别等项目很大了才开始写测试。从现在开始,每个新功能都加个测试,养成习惯。
  • 不用追求100%覆盖率。核心业务逻辑、容易出Bug的地方,测试覆盖好就行。
  • Server Components 测试不了就别强求,抽离逻辑或者用 E2E 测试补充。
  • 遇到报错别慌,按第五章的清单排查,大概率能解决。

把这篇文章里的配置文件保存下来,下次新项目直接复制,十分钟就能跑起测试环境。测试写起来,Bug 少起来,代码质量自然就上去了。

如果你在配置或者写测试的过程中遇到问题,可以留言讨论。我也是从踩坑过来的,能帮忙的肯定帮。

Next.js Jest 测试环境配置完整流程

从零配置 Next.js 15 + Jest + React Testing Library 测试环境的详细步骤

⏱️ 预计耗时: 15 分钟

  1. 1

    步骤1: 安装测试依赖包

    安装 Jest 和 React Testing Library 全家桶:

    • 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(TypeScript 项目需要)

    包说明:
    • jest:测试框架核心
    • jest-environment-jsdom:模拟浏览器 DOM 环境
    • @testing-library/react:React 组件测试工具
    • @testing-library/jest-dom:扩展断言方法(如 toBeInTheDocument)

    一次性安装完成后,不要立即运行测试,先完成配置文件创建。
  2. 2

    步骤2: 创建 Jest 配置文件

    在项目根目录创建 jest.config.ts,使用 next/jest 自动处理 Next.js 特性:

    ```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'],
    // 如果使用路径别名,添加 moduleNameMapper
    moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/lib/(.*)$': '<rootDir>/lib/$1',
    },
    }

    export default createJestConfig(config)
    ```

    next/jest 会自动处理:CSS/图片 Mock、环境变量加载、TypeScript 转换、node_modules 排除。
  3. 3

    步骤3: 创建 Jest 启动配置

    在项目根目录创建 jest.setup.ts,导入 Jest DOM 扩展:

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

    这一行让你可以使用扩展的断言方法:
    • expect(element).toBeInTheDocument()
    • expect(element).toHaveClass('active')
    • expect(element).toBeVisible()
    • expect(element).toHaveTextContent('text')

    不导入这个文件,上述方法都会报 "not a function" 错误。
  4. 4

    步骤4: 配置测试脚本

    在 package.json 添加测试命令:

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

    三个命令用途:
    • npm test:运行所有测试(CI/CD 使用)
    • npm run test:watch:监听模式,文件改动自动测试(开发使用)
    • npm run test:coverage:生成代码覆盖率报告
  5. 5

    步骤5: 创建示例测试验证配置

    创建 __tests__/example.test.ts 验证环境是否配置成功:

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

    运行 npm test,看到绿色 PASS 表示配置成功。

    如果报错,按顺序检查:
    1. jest.config.ts 是否用了 createJestConfig 包裹
    2. jest.setup.ts 是否正确导入
    3. package.json 测试脚本是否正确
    4. 路径别名配置是否与 tsconfig.json 一致

常见问题

为什么必须使用 next/jest 包裹配置?
next/jest 是 Next.js 官方提供的 Jest 配置工具,它会自动处理很多复杂配置:

• 自动 Mock CSS 和图片文件(不会导致测试报错)
• 自动加载 .env 环境变量
• 自动转换 TypeScript 和 JSX
• 自动排除 node_modules 和 .next 目录
• 配置 Next.js Compiler 转换规则

如果不用 next/jest,你需要手动配置以上所有内容,非常繁琐且容易出错。官方强烈推荐使用 createJestConfig 包裹配置。
Server Components 可以用 Jest 测试吗?
部分可以,但有限制:

• 同步 Server Components:可以正常测试
• async Server Components:Jest 不支持,会报 "Objects are not valid as a React child" 错误

推荐做法:
1. 抽离异步逻辑为纯函数,单独测试数据获取逻辑
2. Server Components 只做简单渲染,不包含复杂逻辑
3. 使用 Playwright 或 Cypress 做 E2E 测试覆盖完整流程

不要强求用 Jest 测试所有 Server Components,选对工具更重要。
测试时如何 Mock Next.js 的 useRouter?
Next.js App Router 的路由 Hooks 必须 Mock 才能测试:

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

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

// 在测试中设置返回值
const pushMock = jest.fn()
;(useRouter as jest.Mock).mockReturnValue({
push: pushMock,
back: jest.fn(),
forward: jest.fn(),
})
```

注意 jest.mock 必须放在测试文件顶部(import 之后),不能放在 describe 或 it 里面。
为什么会出现 'Cannot use import statement outside a module' 错误?
这是 Jest 默认不支持 ES Modules 导致的,有两种解决方案:

方案一:配置 transformIgnorePatterns(推荐)
```typescript
// jest.config.ts
const config: Config = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transformIgnorePatterns: [
'node_modules/(?!(nanoid|uuid)/)', // 列出使用 ESM 的包
],
}
```

方案二:确保 next/jest 正确包裹配置
```typescript
import nextJest from 'next/jest'
const createJestConfig = nextJest({ dir: './' })
export default createJestConfig(config)
```

90% 的情况是某个 npm 包使用了 ESM,把包名加到 transformIgnorePatterns 例外列表即可。
如何 Mock API 请求?推荐哪种方式?
有三种常用方法,按复杂度递增:

方法一:Mock 全局 fetch(最简单,适合小项目)
```typescript
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
})
) as jest.Mock
```

方法二:MSW (Mock Service Worker,推荐,适合中大型项目)
• 可以拦截所有网络请求
• 支持复杂的请求/响应逻辑
• 不用每个测试都写 Mock
• 安装:npm install -D msw

方法三:axios-mock-adapter(如果项目用 axios)
• 专门针对 axios 的 Mock 库
• API 简洁,易于使用

推荐使用 MSW,因为它更接近真实网络请求,可复用性强,适合团队协作。
测试运行很慢怎么优化?
有四个实用的优化方法:

1. 并行运行测试(最有效)
```json
{ "scripts": { "test": "jest --maxWorkers=4" } }
```

2. 只测试改动的文件
```bash
npm test -- --onlyChanged
```

3. 禁用代码覆盖率(调试时)
```json
{ "scripts": { "test:fast": "jest --no-coverage" } }
```

4. 跳过慢的测试
```typescript
describe.skip('Slow E2E Tests', () => {
// 这些测试会被跳过
})
```

建议开发时用 --onlyChanged 和 --no-coverage,CI/CD 时跑完整测试。
路径别名 @/ 配置后测试还是报错怎么办?
确保 jest.config.ts 和 tsconfig.json 的路径配置一致:

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

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

注意两点:
1. tsconfig 用相对路径(不带 <rootDir>)
2. jest.config 用绝对路径(带 <rootDir>)
3. 通配符写法要一致(.*)

修改后重启测试,应该就能识别路径别名了。

12 分钟阅读 · 发布于: 2026年1月7日 · 修改于: 2026年1月15日

评论

使用 GitHub 账号登录后即可评论

相关文章