Next.js测试策略 #

一、测试概述 #

1.1 测试类型 #

类型 说明 工具
单元测试 测试单个函数/组件 Jest/Vitest
集成测试 测试组件交互 Testing Library
E2E测试 测试完整流程 Playwright

1.2 测试金字塔 #

text
        /\
       /  \
      / E2E\
     /------\
    / 集成测试 \
   /----------\
  /   单元测试   \
 /--------------\

二、Vitest配置 #

2.1 安装 #

bash
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react

2.2 配置 #

typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
    plugins: [react()],
    test: {
        environment: 'jsdom',
        globals: true,
        setupFiles: './vitest.setup.ts',
    },
})

2.3 setup文件 #

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

2.4 package.json #

json
{
    "scripts": {
        "test": "vitest",
        "test:coverage": "vitest --coverage"
    }
}

三、单元测试 #

3.1 测试工具函数 #

typescript
import { describe, it, expect } from 'vitest'
import { formatDate, slugify } from './utils'

describe('formatDate', () => {
    it('should format date correctly', () => {
        const date = new Date('2024-01-15')
        expect(formatDate(date)).toBe('2024年1月15日')
    })
    
    it('should handle invalid date', () => {
        expect(() => formatDate(new Date('invalid'))).toThrow()
    })
})

describe('slugify', () => {
    it('should convert to slug', () => {
        expect(slugify('Hello World')).toBe('hello-world')
    })
    
    it('should remove special characters', () => {
        expect(slugify('Hello! @World#')).toBe('hello-world')
    })
})

3.2 测试自定义Hook #

tsx
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

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

3.3 测试组件 #

tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Button } from './Button'

describe('Button', () => {
    it('should render correctly', () => {
        render(<Button>Click me</Button>)
        expect(screen.getByText('Click me')).toBeInTheDocument()
    })
    
    it('should handle click', () => {
        const handleClick = vi.fn()
        render(<Button onClick={handleClick}>Click me</Button>)
        
        fireEvent.click(screen.getByText('Click me'))
        
        expect(handleClick).toHaveBeenCalledTimes(1)
    })
    
    it('should be disabled', () => {
        render(<Button disabled>Click me</Button>)
        expect(screen.getByText('Click me')).toBeDisabled()
    })
})

四、集成测试 #

4.1 测试表单 #

tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
    it('should submit form with valid data', async () => {
        const onSuccess = vi.fn()
        render(<LoginForm onSuccess={onSuccess} />)
        
        fireEvent.change(screen.getByLabelText(/邮箱/i), {
            target: { value: 'test@example.com' },
        })
        
        fireEvent.change(screen.getByLabelText(/密码/i), {
            target: { value: 'password123' },
        })
        
        fireEvent.click(screen.getByText(/登录/i))
        
        await waitFor(() => {
            expect(onSuccess).toHaveBeenCalled()
        })
    })
    
    it('should show error with invalid data', async () => {
        render(<LoginForm />)
        
        fireEvent.click(screen.getByText(/登录/i))
        
        await waitFor(() => {
            expect(screen.getByText(/请输入邮箱/i)).toBeInTheDocument()
        })
    })
})

4.2 测试列表 #

tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { UserList } from './UserList'

const mockUsers = [
    { id: '1', name: 'User 1', email: 'user1@example.com' },
    { id: '2', name: 'User 2', email: 'user2@example.com' },
]

describe('UserList', () => {
    it('should render users', () => {
        render(<UserList users={mockUsers} />)
        
        expect(screen.getByText('User 1')).toBeInTheDocument()
        expect(screen.getByText('User 2')).toBeInTheDocument()
    })
    
    it('should show empty message', () => {
        render(<UserList users={[]} />)
        
        expect(screen.getByText(/暂无用户/i)).toBeInTheDocument()
    })
})

五、E2E测试 #

5.1 安装Playwright #

bash
npm install -D @playwright/test
npx playwright install

5.2 配置 #

typescript
import { defineConfig } from '@playwright/test'

export default defineConfig({
    testDir: './e2e',
    fullyParallel: true,
    use: {
        baseURL: 'http://localhost:3000',
    },
    webServer: {
        command: 'npm run dev',
        url: 'http://localhost:3000',
        reuseExistingServer: !process.env.CI,
    },
})

5.3 测试用例 #

typescript
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
    test('should login successfully', 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.locator('h1')).toContainText('欢迎')
    })
    
    test('should show error with invalid credentials', async ({ page }) => {
        await page.goto('/login')
        
        await page.fill('input[name="email"]', 'wrong@example.com')
        await page.fill('input[name="password"]', 'wrongpassword')
        await page.click('button[type="submit"]')
        
        await expect(page.locator('.error-message')).toContainText('登录失败')
    })
})

test.describe('Blog', () => {
    test('should display blog posts', async ({ page }) => {
        await page.goto('/blog')
        
        await expect(page.locator('article')).toHaveCount(10)
    })
    
    test('should navigate to post detail', async ({ page }) => {
        await page.goto('/blog')
        
        await page.click('article:first-child a')
        
        await expect(page).toHaveURL(/\/blog\/.+/)
    })
})

六、Mock #

6.1 Mock API #

typescript
import { vi } from 'vitest'

global.fetch = vi.fn()

describe('API calls', () => {
    beforeEach(() => {
        vi.clearAllMocks()
    })
    
    it('should fetch users', async () => {
        const mockUsers = [{ id: '1', name: 'User 1' }]
        
        ;(fetch as any).mockResolvedValueOnce({
            ok: true,
            json: async () => mockUsers,
        })
        
        const users = await getUsers()
        
        expect(users).toEqual(mockUsers)
        expect(fetch).toHaveBeenCalledWith('/api/users')
    })
})

6.2 Mock Server Actions #

typescript
import { vi } from 'vitest'

vi.mock('./actions', () => ({
    createPost: vi.fn(),
}))

describe('CreatePostForm', () => {
    it('should call createPost on submit', async () => {
        const createPost = vi.fn().mockResolvedValue({ success: true })
        vi.mocked(createPost).mockImplementation(createPost)
        
        render(<CreatePostForm />)
        
        fireEvent.change(screen.getByLabelText(/标题/i), {
            target: { value: 'Test Post' },
        })
        
        fireEvent.click(screen.getByText(/发布/i))
        
        await waitFor(() => {
            expect(createPost).toHaveBeenCalled()
        })
    })
})

七、最佳实践 #

7.1 测试命名 #

typescript
describe('Component', () => {
    it('should do something when condition', () => {
        // 测试代码
    })
})

7.2 AAA模式 #

typescript
it('should increment counter', () => {
    // Arrange
    const { result } = renderHook(() => useCounter())
    
    // Act
    act(() => {
        result.current.increment()
    })
    
    // Assert
    expect(result.current.count).toBe(1)
})

7.3 测试覆盖 #

bash
npm run test:coverage

八、总结 #

测试策略要点:

要点 说明
单元测试 Vitest
组件测试 Testing Library
E2E测试 Playwright
Mock vi.fn()
覆盖率 coverage

恭喜你完成Next.js完全指南的学习!

最后更新:2026-03-28