02-测试技术
📋 学习目标
掌握单元测试编写
学习组件测试技巧
理解E2E测试流程
掌握测试覆盖率分析
🧪 测试框架
Vitest(推荐)
# 安装
pnpm add -D vitest @vitest/ui
# package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
基本配置
// vitest.config.ts
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // 或'node', 'happy-dom'
globals: true,
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html']
}
}
});
📝 单元测试
基本断言
import {describe, it, expect} from 'vitest';
describe('Math utils', () => {
it('should add two numbers', () => {
expect(1 + 1).toBe(2);
});
it('should multiply numbers', () => {
const result = multiply(3, 4);
expect(result).toBe(12);
expect(result).toBeGreaterThan(10);
expect(result).toBeLessThan(20);
});
it('should handle objects', () => {
const user = {name: 'John', age: 30};
expect(user).toEqual({name: 'John', age: 30});
expect(user).toHaveProperty('name');
expect(user).toMatchObject({name: 'John'});
});
it('should handle arrays', () => {
const arr = [1, 2, 3];
expect(arr).toHaveLength(3);
expect(arr).toContain(2);
expect(arr).toEqual([1, 2, 3]);
});
});
Mock函数
import {vi} from 'vitest';
describe('Mock functions', () => {
it('should track calls', () => {
const mockFn = vi.fn();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
});
it('should return values', () => {
const mockFn = vi.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
expect(mockFn()).toBe('first');
expect(mockFn()).toBe('second');
expect(mockFn()).toBe('default');
});
it('should mock implementation', () => {
const mockFn = vi.fn((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);
});
});
异步测试
describe('Async tests', () => {
it('should handle promises', async () => {
const data = await fetchData();
expect(data).toEqual({id: 1});
});
it('should handle async/await', async () => {
const result = await asyncFunction();
expect(result).toBe('success');
});
it('should handle errors', async () => {
await expect(failingAsync()).rejects.toThrow('Error');
});
it('should timeout', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
}, {timeout: 1000});
});
⚛️ React组件测试
React Testing Library
# 安装
pnpm add -D @testing-library/react @testing-library/jest-dom
基本测试
import {render, screen, fireEvent} from '@testing-library/react';
import {describe, it, expect} from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('should render initial count', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('should increment count', () => {
render(<Counter />);
const button = screen.getByRole('button', {name: /increment/i});
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('should decrement count', async () => {
render(<Counter />);
const button = screen.getByRole('button', {name: /decrement/i});
await userEvent.click(button);
expect(screen.getByText('Count: -1')).toBeInTheDocument();
});
});
测试Hooks
import {renderHook, act} from '@testing-library/react';
import {useCounter} from './useCounter';
describe('useCounter', () => {
it('should initialize with 0', () => {
const {result} = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should increment', () => {
const {result} = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
测试异步组件
import {render, screen, waitFor} from '@testing-library/react';
describe('UserList', () => {
it('should load and display users', async () => {
render(<UserList />);
// 等待加载完成
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
it('should handle errors', async () => {
// Mock API错误
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
💚 Vue组件测试
Vue Test Utils
# 安装
pnpm add -D @vue/test-utils
基本测试
import {mount} from '@vue/test-utils';
import {describe, it, expect} from 'vitest';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
it('should render count', () => {
const wrapper = mount(Counter);
expect(wrapper.text()).toContain('Count: 0');
});
it('should increment', async () => {
const wrapper = mount(Counter);
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Count: 1');
});
it('should accept props', () => {
const wrapper = mount(Counter, {
props: {
initialCount: 10
}
});
expect(wrapper.text()).toContain('Count: 10');
});
});
测试组合式函数
import {useCounter} from './useCounter';
describe('useCounter', () => {
it('should increment', () => {
const {count, increment} = useCounter();
expect(count.value).toBe(0);
increment();
expect(count.value).toBe(1);
});
});
🌐 E2E测试
Playwright(推荐)
# 安装
pnpm create playwright
# 运行测试
pnpm playwright test
pnpm playwright test --ui
pnpm playwright show-report
基本E2E测试
import {test, expect} from '@playwright/test';
test.describe('Login flow', () => {
test('should login successfully', async ({page}) => {
// 访问页面
await page.goto('http://localhost:3000/login');
// 填写表单
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
// 点击按钮
await page.click('button[type="submit"]');
// 验证跳转
await expect(page).toHaveURL('http://localhost:3000/dashboard');
// 验证元素
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('should show error for invalid credentials', async ({page}) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toBeVisible();
});
});
高级E2E测试
test.describe('Shopping cart', () => {
test.beforeEach(async ({page}) => {
// 登录
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
});
test('should add item to cart', async ({page}) => {
await page.goto('/products');
// 点击第一个产品
await page.click('.product-card:first-child button');
// 验证购物车数量
const badge = page.locator('.cart-badge');
await expect(badge).toHaveText('1');
// 截图
await page.screenshot({path: 'cart.png'});
});
});
📊 测试覆盖率
配置覆盖率
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'tests/',
'**/*.test.{js,ts,jsx,tsx}',
'**/*.config.{js,ts}',
'**/types.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});
查看覆盖率
# 生成覆盖率报告
pnpm test:coverage
# 打开HTML报告
open coverage/index.html
🎯 测试最佳实践
1. AAA模式
test('should add item to list', () => {
// Arrange(准备)
const list = new List();
const item = {id: 1, name: 'Item'};
// Act(执行)
list.add(item);
// Assert(断言)
expect(list.items).toHaveLength(1);
expect(list.items[0]).toEqual(item);
});
2. 测试隔离
describe('User service', () => {
let userService;
beforeEach(() => {
// 每个测试前重置
userService = new UserService();
});
afterEach(() => {
// 清理
userService.clear();
});
it('should create user', () => {
const user = userService.create('John');
expect(user.name).toBe('John');
});
});
3. Mock外部依赖
import {vi} from 'vitest';
import {fetchUser} from './api';
vi.mock('./api', () => ({
fetchUser: vi.fn()
}));
test('should fetch user', async () => {
fetchUser.mockResolvedValue({id: 1, name: 'John'});
const user = await getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user.name).toBe('John');
});
4. 快照测试
import {render} from '@testing-library/react';
test('should match snapshot', () => {
const {container} = render(<Button>Click me</Button>);
expect(container).toMatchSnapshot();
});
🔄 持续集成
GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
📚 测试工具推荐
单元测试
Vitest
Jest
Mocha + Chai
组件测试
React Testing Library
Vue Test Utils
Enzyme(React旧项目)
E2E测试
Playwright
Cypress
Puppeteer
其他工具
MSW:API Mock
Faker.js:测试数据生成
Testing Playground:选择器辅助