# 02-测试技术
## 📋 学习目标
- 掌握单元测试编写
- 学习组件测试技巧
- 理解E2E测试流程
- 掌握测试覆盖率分析
## 🧪 测试框架
### Vitest(推荐)
```bash
# 安装
pnpm add -D vitest @vitest/ui
# package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
```
### 基本配置
```javascript
// 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']
}
}
});
```
## 📝 单元测试
### 基本断言
```javascript
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函数
```javascript
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);
});
});
```
### 异步测试
```javascript
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
```bash
# 安装
pnpm add -D @testing-library/react @testing-library/jest-dom
```
### 基本测试
```jsx
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();
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('should increment count', () => {
render();
const button = screen.getByRole('button', {name: /increment/i});
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('should decrement count', async () => {
render();
const button = screen.getByRole('button', {name: /decrement/i});
await userEvent.click(button);
expect(screen.getByText('Count: -1')).toBeInTheDocument();
});
});
```
### 测试Hooks
```javascript
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);
});
});
```
### 测试异步组件
```jsx
import {render, screen, waitFor} from '@testing-library/react';
describe('UserList', () => {
it('should load and display users', async () => {
render();
// 等待加载完成
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
it('should handle errors', async () => {
// Mock API错误
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error'));
render();
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
```
## 💚 Vue组件测试
### Vue Test Utils
```bash
# 安装
pnpm add -D @vue/test-utils
```
### 基本测试
```javascript
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');
});
});
```
### 测试组合式函数
```javascript
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(推荐)
```bash
# 安装
pnpm create playwright
# 运行测试
pnpm playwright test
pnpm playwright test --ui
pnpm playwright show-report
```
### 基本E2E测试
```javascript
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测试
```javascript
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'});
});
});
```
## 📊 测试覆盖率
### 配置覆盖率
```javascript
// 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
}
}
}
});
```
### 查看覆盖率
```bash
# 生成覆盖率报告
pnpm test:coverage
# 打开HTML报告
open coverage/index.html
```
## 🎯 测试最佳实践
### 1. AAA模式
```javascript
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. 测试隔离
```javascript
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外部依赖
```javascript
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. 快照测试
```tsx
import {render} from '@testing-library/react';
test('should match snapshot', () => {
const {container} = render();
expect(container).toMatchSnapshot();
});
```
## 🔄 持续集成
### GitHub Actions
```yaml
# .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:选择器辅助
## 📚 参考资料
- [Vitest文档](https://vitest.dev/)
- [React Testing Library](https://testing-library.com/react)
- [Vue Test Utils](https://test-utils.vuejs.org/)
- [Playwright文档](https://playwright.dev/)