Next.js 的测试策略对于确保应用质量和稳定性至关重要。Next.js 应用需要测试多个层面,包括组件、页面、API 路由、数据获取逻辑等。
测试工具选择
1. 主要测试框架
javascript// package.json { "devDependencies": { "@testing-library/react": "^14.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.0.0", "jest-environment-jsdom": "^29.0.0", "@playwright/test": "^1.40.0", "msw": "^2.0.0", "vitest": "^1.0.0", "@vitejs/plugin-react": "^4.0.0" } }
2. Jest 配置
javascript// jest.config.js const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './', }) const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1', }, collectCoverageFrom: [ 'app/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}', 'lib/**/*.{js,jsx,ts,tsx}', '!**/*.d.ts', '!**/node_modules/**', ], testMatch: [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)' ], } module.exports = createJestConfig(customJestConfig) // jest.setup.js import '@testing-library/jest-dom'
组件测试
1. 基础组件测试
javascript// components/__tests__/Button.test.js import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Button from '../Button' describe('Button Component', () => { it('renders button with text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', async () => { const user = userEvent.setup() const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) await user.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) }) it('is disabled when disabled prop is true', () => { render(<Button disabled>Click me</Button>) expect(screen.getByRole('button')).toBeDisabled() }) it('applies correct variant styles', () => { const { rerender } = render(<Button variant="primary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-primary') rerender(<Button variant="secondary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-secondary') }) })
2. 表单组件测试
javascript// components/__tests__/LoginForm.test.js import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import LoginForm from '../LoginForm' describe('LoginForm Component', () => { it('renders email and password inputs', () => { render(<LoginForm />) expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument() }) it('shows validation errors for empty fields', async () => { const user = userEvent.setup() render(<LoginForm />) const submitButton = screen.getByRole('button', { name: /login/i }) await user.click(submitButton) await waitFor(() => { expect(screen.getByText(/email is required/i)).toBeInTheDocument() expect(screen.getByText(/password is required/i)).toBeInTheDocument() }) }) it('submits form with valid data', async () => { const user = userEvent.setup() const handleSubmit = jest.fn() render(<LoginForm onSubmit={handleSubmit} />) await user.type(screen.getByLabelText(/email/i), 'test@example.com') await user.type(screen.getByLabelText(/password/i), 'password123') await user.click(screen.getByRole('button', { name: /login/i })) await waitFor(() => { expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }) }) }) })
3. 带有 API 调用的组件测试
javascript// components/__tests__/UserList.test.js import { render, screen, waitFor } from '@testing-library/react' import { rest } from 'msw' import { setupServer } from 'msw/node' import UserList from '../UserList' const server = setupServer( rest.get('/api/users', (req, res, ctx) => { return res( ctx.json([ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com' } ]) ) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe('UserList Component', () => { it('displays loading state initially', () => { render(<UserList />) expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('displays users after successful fetch', async () => { render(<UserList />) await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument() expect(screen.getByText('Jane Smith')).toBeInTheDocument() }) }) it('displays error message on failed fetch', async () => { server.use( rest.get('/api/users', (req, res, ctx) => { return res(ctx.status(500)) }) ) render(<UserList />) await waitFor(() => { expect(screen.getByText(/failed to load users/i)).toBeInTheDocument() }) }) })
页面测试
1. 静态页面测试
javascript// app/__tests__/page.test.js import { render, screen } from '@testing-library/react' import Home from '../page' describe('Home Page', () => { it('renders hero section', () => { render(<Home />) expect(screen.getByText(/welcome to our website/i)).toBeInTheDocument() }) it('renders navigation links', () => { render(<Home />) expect(screen.getByRole('link', { name: /about/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument() }) })
2. 动态页面测试
javascript// app/posts/[slug]/__tests__/page.test.js import { render, screen, waitFor } from '@testing-library/react' import { rest } from 'msw' import { setupServer } from 'msw/node' import PostPage from '../page' const server = setupServer( rest.get('/api/posts/:slug', (req, res, ctx) => { const { slug } = req.params return res( ctx.json({ id: 1, slug, title: 'Test Post', content: 'This is a test post content', author: 'John Doe' }) ) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe('Post Page', () => { it('renders post content', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText('Test Post')).toBeInTheDocument() expect(screen.getByText('This is a test post content')).toBeInTheDocument() }) }) it('displays author information', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText(/by john doe/i)).toBeInTheDocument() }) }) })
API 路由测试
1. GET 请求测试
javascript// app/api/users/__tests__/route.test.js import { GET } from '../route' import { NextRequest } from 'next/server' describe('/api/users GET endpoint', () => { it('returns list of users', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.status).toBe(200) const data = await response.json() expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) it('includes proper headers', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.headers.get('content-type')).toBe('application/json') }) })
2. POST 请求测试
javascript// app/api/users/__tests__/route.test.js import { POST } from '../route' import { NextRequest } from 'next/server' describe('/api/users POST endpoint', () => { it('creates a new user', async () => { const userData = { name: 'John Doe', email: 'john@example.com' } const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify(userData), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(201) const data = await response.json() expect(data.id).toBeDefined() expect(data.name).toBe(userData.name) expect(data.email).toBe(userData.email) }) it('validates required fields', async () => { const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify({ name: 'John' }), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBeDefined() }) })
E2E 测试(Playwright)
1. 基础 E2E 测试
javascript// e2e/home.spec.ts import { test, expect } from '@playwright/test' test.describe('Home Page', () => { test('loads homepage successfully', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle(/My Next.js App/) await expect(page.getByText('Welcome')).toBeVisible() }) test('navigates to about page', async ({ page }) => { await page.goto('/') await page.click('text=About') await expect(page).toHaveURL('/about') await expect(page.getByText('About Us')).toBeVisible() }) test('search functionality works', async ({ page }) => { await page.goto('/') await page.fill('input[name="search"]', 'Next.js') await page.click('button[type="submit"]') await expect(page).toHaveURL('/search?q=Next.js') await expect(page.getByText('Search Results')).toBeVisible() }) })
2. 表单提交测试
javascript// e2e/login.spec.ts import { test, expect } from '@playwright/test' test.describe('Login Flow', () => { test('successful login', async ({ page }) => { await page.goto('/login') await page.fill('input[name="email"]', 'test@example.com') await page.fill('input[name="password"]', 'password123') await page.click('button[type="submit"]') await expect(page).toHaveURL('/dashboard') await expect(page.getByText('Welcome back')).toBeVisible() }) test('shows error for invalid credentials', async ({ page }) => { await page.goto('/login') await page.fill('input[name="email"]', 'invalid@example.com') await page.fill('input[name="password"]', 'wrongpassword') await page.click('button[type="submit"]') await expect(page.getByText('Invalid credentials')).toBeVisible() await expect(page).toHaveURL('/login') }) })
3. 响应式测试
javascript// e2e/responsive.spec.ts import { test, expect } from '@playwright/test' test.describe('Responsive Design', () => { test('mobile navigation works', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) await page.goto('/') const menuButton = page.getByRole('button', { name: /menu/i }) await expect(menuButton).toBeVisible() await menuButton.click() await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() }) test('desktop navigation is always visible', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }) await page.goto('/') await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() }) })
测试最佳实践
1. 测试组织结构
shell├── __tests__/ │ ├── setup.js │ └── utils.js ├── components/ │ └── __tests__/ │ ├── Button.test.js │ └── LoginForm.test.js ├── app/ │ ├── __tests__/ │ │ └── page.test.js │ └── api/ │ └── __tests__/ │ └── route.test.js ├── lib/ │ └── __tests__/ │ └── utils.test.js └── e2e/ ├── home.spec.ts ├── login.spec.ts └── responsive.spec.ts
2. 测试覆盖率
javascript// vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'e2e/', '**/*.config.*', '**/*.test.*', '**/*.spec.*' ], thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 } } } })
3. 测试工具函数
javascript// __tests__/utils.js import { render } from '@testing-library/react' import { RouterContext } from 'next/dist/shared/lib/router-context' export function renderWithRouter(ui, { route = '/' } = {}) { return render( <RouterContext.Provider value={{ router: { asPath: route } }}> {ui} </RouterContext.Provider> ) } export function waitForLoadingToFinish(screen) { return screen.findByText(/loading/i).then(() => { return screen.findByText(/loading/i).then(el => { return !el }) }) }
完整的测试策略包括单元测试、集成测试和端到端测试,确保 Next.js 应用的各个层面都得到充分验证。