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。如果看到绿色的 PASS 和 1 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('请输入有效的邮箱')
})
})测试思路:
- 用
render()渲染组件 - 用
screen.getByXxx()找到元素(按角色、文本、占位符等) - 用
fireEvent模拟用户操作 - 用
expect()断言结果
这里有个小技巧:尽量用 getByRole 而不是 getByTestId。因为 role(比如 button、alert)更贴近用户实际看到的东西,测试也更稳定。
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用来渲染 Hookact包裹状态更新操作(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(useRouter、usePathname、useSearchParams)在测试环境里默认不可用,必须 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-adapterimport 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/testMock 第三方模块(以 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 错误及解决方案
问题 1:Cannot 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 包(比如 nanoid、uuid),把包名加到 transformIgnorePatterns 的例外列表里:
transformIgnorePatterns: [
'node_modules/(?!(nanoid|uuid)/)',
]错误 2:Unexpected token ‘export’
原因:类似上面,某个文件没有被 Jest 转换。
解决方案:检查 jest.config.ts 的 transform 和 transformIgnorePatterns 配置。确保 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 不支持。
解决方案:
- 把异步逻辑抽离成纯函数,单独测试
- 或者用 E2E 测试(Playwright、Cypress)
别强求用 Jest 测试 Server Components,工具不是万能的。
错误 5:act(…) warning
完整报错:
Warning: An update to Component inside a test was not wrapped in act(...).原因:组件里有异步状态更新(比如 useEffect、setTimeout),测试没有等待更新完成。
解决方案:用 waitFor 或 act 包裹异步操作:
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 />)
})
})测试运行慢?试试这些优化
- 并行运行测试:
{
"scripts": {
"test": "jest --maxWorkers=4"
}
}- 只测试改动的文件:
npm test -- --onlyChanged- 禁用代码覆盖率(调试时):
{
"scripts": {
"test:fast": "jest --no-coverage"
}
}- 用
test.skip跳过慢的测试:
describe.skip('Slow Tests', () => {
// 这些测试会被跳过
})问题诊断清单
遇到报错时,按这个顺序检查:
- ✅
jest.config.ts是否用了next/jest包裹配置? - ✅
jest.setup.ts是否正确导入@testing-library/jest-dom? - ✅ 路径别名配置是否和
tsconfig.json一致? - ✅ 需要 Mock 的模块(
next/navigation、next/image)是否已 Mock? - ✅ 异步操作是否用了
waitFor或act? - ✅ 依赖版本是否兼容(特别是 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: 安装测试依赖包
安装 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: 创建 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: 创建 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: 配置测试脚本
在 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: 创建示例测试验证配置
创建 __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 包裹配置?
• 自动 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?
```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' 错误?
方案一:配置 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 时跑完整测试。
路径别名 @/ 配置后测试还是报错怎么办?
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 账号登录后即可评论