6月5日 14:00

Next.js测试策略:服务端组件、API路由、Server Actions和E2E

Next.js 应用的测试比纯 React 复杂——它有服务端组件、客户端组件、API 路由、Server Actions、还有 SSR/SSG 渲染模式。照搬 React 的测试方法会踩很多坑。这篇文章按组件类型分类,把每类该测什么、用什么工具、怎么 Mock 讲清楚。

测试工具选择

工具用途替代方案
Jest + React Testing Library组件单元测试Vitest(更快,配置类似)
MSW (Mock Service Worker)Mock API 请求nock(只 Node 环境)
PlaywrightE2E 测试Cypress
@testing-library/user-event模拟用户交互fireEvent(不如 user-event 贴近真实)

Next.js 项目推荐用 Vitest 代替 Jest——Vite 生态集成更好,速度快 3-5 倍:

bash
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event

Vitest 配置

typescript
// vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, });
typescript
// vitest.setup.ts import '@testing-library/jest-dom';

客户端组件测试

客户端组件(文件顶部有 'use client')的测试和普通 React 组件一样:

typescript
// components/Counter.tsx 'use client'; import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return ( <div> <span data-testid="count">{count}</span> <button onClick={() => setCount(c => c + 1)}>+1</button> </div> ); }
typescript
// components/__tests__/Counter.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Counter } from '../Counter'; describe('Counter', () => { it('increments count on click', async () => { const user = userEvent.setup(); render(<Counter />); expect(screen.getByTestId('count')).toHaveTextContent('0'); await user.click(screen.getByRole('button', { name: '+1' })); expect(screen.getByTestId('count')).toHaveTextContent('1'); }); });

Mock hooks

测试依赖 useSearchParams 等 Next.js hooks 的组件:

typescript
// Mock next/navigation vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('tab=settings'), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => '/dashboard', }));

Next.js 的 hooks 在测试环境里不工作——它们依赖 Next.js 的路由上下文。Mock 是唯一的方式。

服务端组件测试

服务端组件(默认,没有 'use client')不能直接用 React Testing Library 渲染——它们是异步函数,返回的不是标准 JSX。

方案一:抽取逻辑为纯函数

最佳做法——把业务逻辑从服务端组件里抽出来,单独测试:

typescript
// lib/filterProducts.ts export function filterProducts(products: Product[], category: string) { return products.filter(p => p.category === category); } // lib/__tests__/filterProducts.test.ts import { filterProducts } from '../filterProducts'; describe('filterProducts', () => { it('filters by category', () => { const products = [ { id: 1, name: 'Widget', category: 'tools' }, { id: 2, name: 'Book', category: 'books' }, ]; expect(filterProducts(products, 'tools')).toHaveLength(1); }); });

服务端组件本身只是数据获取 + 渲染的组合,逻辑都在纯函数里——纯函数好测、快、不依赖任何框架。

方案二:测试渲染输出

如果必须测服务端组件的渲染结果,用 renderToString

typescript
import { renderToString } from 'react-dom/server'; import { ProductList } from '../components/ProductList'; it('renders product names', async () => { const products = [{ id: 1, name: 'Widget' }]; const html = renderToString(await ProductList({ products })); expect(html).toContain('Widget'); });

这种方式比较粗糙——只能断言 HTML 字符串包含什么,不能用 screen.getByRole 等 DOM 查询 API。适合快速验证组件不会报错、渲染了关键内容。

API 路由测试

Next.js App Router 的 API 路由是 route.ts 文件,导出 GETPOST 等函数。测试时直接调用这些函数:

typescript
// app/api/users/route.ts import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const page = searchParams.get('page') || '1'; const users = await fetchUsers(Number(page)); return NextResponse.json({ users, page: Number(page) }); }
typescript
// __tests__/api/users.test.ts import { GET } from '@/app/api/users/route'; // Mock 数据获取 vi.mock('@/lib/data', () => ({ fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]), })); describe('GET /api/users', () => { it('returns users with page number', async () => { const request = new Request('http://localhost/api/users?page=2'); const response = await GET(request); const data = await response.json(); expect(response.status).toBe(200); expect(data.page).toBe(2); expect(data.users).toHaveLength(1); }); });

直接构造 Request 对象传入——不需要启动服务器,测试跑在 Node 环境里,速度极快。

Mock 外部 API

API 路由通常要调外部服务。用 MSW 拦截请求:

typescript
import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; const server = setupServer( http.get('https://api.example.com/users', () => { return HttpResponse.json([{ id: 1, name: 'Mocked User' }]); }), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

MSW 在 Node 层面拦截 HTTP 请求——不需要改业务代码,测试完自动恢复。

Server Actions 测试

Server Actions 是 Next.js 13+ 的服务端函数,在客户端通过 useServer 调用。测试方式和 API 路由类似——直接调用函数:

typescript
// app/actions/createPost.ts 'use server'; export async function createPost(formData: FormData) { const title = formData.get('title') as string; if (!title || title.length < 3) { return { error: '标题至少 3 个字符' }; } await db.post.create({ data: { title } }); return { success: true }; }
typescript
import { createPost } from '@/app/actions/createPost'; vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn().mockResolvedValue({ id: 1 }) } }, })); describe('createPost', () => { it('rejects short titles', async () => { const formData = new FormData(); formData.set('title', 'ab'); const result = await createPost(formData); expect(result.error).toBeDefined(); }); it('creates post with valid title', async () => { const formData = new FormData(); formData.set('title', 'My Post'); const result = await createPost(formData); expect(result.success).toBe(true); }); });

E2E 测试(Playwright)

单元测试验证组件逻辑,E2E 测试验证完整用户流程——从打开页面到完成操作。

基础配置

typescript
// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', baseURL: 'http://localhost:3000', use: { locale: 'zh-CN', }, });

页面测试

typescript
// e2e/home.spec.ts import { test, expect } from '@playwright/test'; test('homepage shows products', async ({ page }) => { await page.goto('/'); await expect(page.getByText('热门商品')).toBeVisible(); await expect(page.getByTestId('product-card')).toHaveCount(10); }); test('can add product to cart', async ({ page }) => { await page.goto('/'); await page.getByTestId('product-card').first().getByRole('button', { name: '加入购物车' }).click(); await expect(page.getByTestId('cart-count')).toHaveText('1'); });

E2E 测试需要 Next.js 服务器在跑。Playwright 的 webServer 配置可以自动启动:

typescript
export default defineConfig({ webServer: { command: 'npm run dev', port: 3000, reuseExistingServer: !process.env.CI, }, });

测试策略总结

层级测试什么工具占比
纯函数/工具业务逻辑、数据转换Vitest40%
客户端组件交互、状态、渲染RTL + Vitest25%
API 路由/Actions请求处理、验证、错误Vitest + MSW20%
E2E关键用户流程Playwright15%

服务端组件不直接测——逻辑抽到纯函数,渲染验证留给 E2E。这样 85% 的测试跑在 Vitest 里(< 100ms/个),只有 15% 需要启动浏览器。

标签:Next.js