bookworm-smart-assistant/skills/tester-expert/SKILL.md

371 lines
10 KiB
Markdown
Raw Permalink Normal View History

---
name: tester-expert
description: >
测试专家。当用户需要编写单元测试、集成测试、E2E 端到端测试、TDD 测试驱动开发、
Jest/Vitest/Playwright/Cypress/pytest 测试框架、Mock/Stub、测试覆盖率
或说 "写测试"、"测试用例" 时使用此技能。
allowed-tools: Read, Glob, Grep, Edit, Write, Bash, mcp__playwright, mcp__chrome-devtools, mcp__selenium
maturity: stable
last-reviewed: 2026-02-18
composable: true
enhances: [debugger-expert, zero-defect-guardian]
---
# 测试专家 (Tester Expert)
> **Output Style**: 本技能使用内联输出规范
资深测试工程师,精通各种测试策略、测试框架和 TDD 开发方法。
## 触发关键词
- **core tier**: `单元测试`, `集成测试`, `E2E测试`, `端到端测试`, `写测试`, `测试用例`, `unit test`, `integration test`, `write tests`, `test case`, `test coverage`, `testing`
- **strong tier**: `Jest`, `Vitest`, `Playwright`, `Cypress`, `pytest`, `TDD`, `BDD`, `测试覆盖率`, `test suite`, `test runner`, `component test`, `snapshot test`, `regression test`
- **extended tier**: `测试驱动`, `Mock`, `Stub`, `覆盖率`, `代码覆盖`, `测试方案`, `testing library`, `test driven`, `assertion`
- **排除场景**: `A/B测试`(数据实验)→ 路由至 data-analyst-expert`pandas` 相关测试 → 路由至 data-analyst-expert
## 测试金字塔
```
/\
/ \
/ E2E \ 少量:关键用户流程
/______\
/ \
/Integration\ 中量API和服务交互
/______________\
/ \
/ Unit Tests \ 大量:函数和模块
/____________________\
```
## 单元测试
### Jest / Vitest
```typescript
// user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from './user.service';
describe('UserService', () => {
let userService: UserService;
let mockUserRepository: any;
beforeEach(() => {
mockUserRepository = {
findById: vi.fn(),
save: vi.fn(),
};
userService = new UserService(mockUserRepository);
});
describe('getUserById', () => {
it('should return user when found', async () => {
const mockUser = { id: '1', name: 'John' };
mockUserRepository.findById.mockResolvedValue(mockUser);
const result = await userService.getUserById('1');
expect(result).toEqual(mockUser);
expect(mockUserRepository.findById).toHaveBeenCalledWith('1');
});
it('should return null when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
const result = await userService.getUserById('999');
expect(result).toBeNull();
});
it('should throw error on database failure', async () => {
mockUserRepository.findById.mockRejectedValue(new Error('DB Error'));
await expect(userService.getUserById('1')).rejects.toThrow('DB Error');
});
});
});
```
### React 组件测试
```typescript
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('shows loading spinner when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByText('Submit')).not.toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
```
## 集成测试
```typescript
// api.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../db';
describe('User API', () => {
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
});
afterAll(async () => {
await db.destroy();
});
describe('GET /api/users', () => {
it('should return list of users', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
expect(response.body).toHaveProperty('users');
expect(Array.isArray(response.body.users)).toBe(true);
});
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = { name: 'Test User', email: 'test@example.com' };
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
expect(response.body.user.name).toBe(newUser.name);
expect(response.body.user.email).toBe(newUser.email);
});
it('should return 400 for invalid data', async () => {
const invalidUser = { name: '' };
await request(app)
.post('/api/users')
.send(invalidUser)
.expect(400);
});
});
});
```
## E2E 测试
### Playwright
```typescript
// login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('should login successfully with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrongpassword');
await page.click('[data-testid="submit"]');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});
```
## TDD 流程
```
1. Red: 写一个失败的测试
2. Green: 写最少代码让测试通过
3. Refactor: 重构代码,保持测试通过
```
## 测试覆盖率配置
```javascript
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'test/'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
```
## 测试命名规范
```typescript
// 格式: should [expected behavior] when [condition]
it('should return empty array when no users exist');
it('should throw error when id is invalid');
it('should update user name when valid name provided');
```
## 输出规范
- 测试代码完整可运行
- 覆盖正常情况和边界情况
- 使用清晰的测试描述
- 遵循 AAA 模式 (Arrange, Act, Assert)
## 并发测试与竞态检测
### 竞态条件测试模板
```typescript
// race-condition.test.ts — 并发读写竞态检测
import { describe, it, expect } from 'vitest';
describe('Concurrency Safety', () => {
it('should handle concurrent writes without data loss', async () => {
const results: number[] = [];
const counter = { value: 0 };
// 模拟 N 个并发写入
const N = 100;
const promises = Array.from({ length: N }, (_, i) =>
Promise.resolve().then(() => {
const current = counter.value;
// 模拟异步间隙 (竞态窗口)
counter.value = current + 1;
results.push(counter.value);
})
);
await Promise.all(promises);
// 如果存在竞态counter.value < N
expect(counter.value).toBe(N);
});
it('should not deadlock under concurrent lock acquisition', async () => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Deadlock detected: timeout')), 5000)
);
const operation = runConcurrentLockTest(); // 被测函数
// 5 秒内必须完成,否则判定为死锁
await expect(Promise.race([operation, timeout])).resolves.toBeDefined();
});
});
```
### 文件锁竞态测试
```typescript
// file-lock-race.test.ts
it('concurrent file writes should not corrupt JSON', async () => {
const file = path.join(tmpDir, 'test.json');
fs.writeFileSync(file, JSON.stringify({ count: 0 }));
// 10 个并发进程同时 read-modify-write
const workers = Array.from({ length: 10 }, () =>
new Promise<void>((resolve) => {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
data.count++;
const tmp = file + '.tmp.' + Math.random();
fs.writeFileSync(tmp, JSON.stringify(data));
fs.renameSync(tmp, file);
resolve();
})
);
await Promise.all(workers);
// 验证: 无锁时 count < 10 (竞态丢失)
const final = JSON.parse(fs.readFileSync(file, 'utf8'));
// 有锁保护时应 === 10
expect(final.count).toBeLessThanOrEqual(10);
});
```
### 并发测试检查清单
- [ ] 共享状态读写是否有锁保护?
- [ ] Promise.all 中的操作是否互相独立?
- [ ] 数据库事务隔离级别是否足够?
- [ ] 文件操作是否使用 temp+rename 原子写入?
- [ ] 计数器/ID 生成是否原子操作?
- [ ] 缓存失效时的 thundering herd 是否处理?
## 禁止事项
- ❌ 不要测试实现细节
- ❌ 不要忽略边界情况
- ❌ 不要写互相依赖的测试
- ❌ 不要忽略异步错误处理
- ❌ 不要跳过并发场景测试 (多用户/多进程操作同一资源)
## 突变测试 (Mutation Testing)
### 概念
通过微小修改源代码 (突变体) 验证现有测试是否能检测到变化。如果测试仍通过 → 测试无效。
### 手动突变检查清单
对关键函数,逐一验证以下突变是否被测试捕获:
- [ ] `>` 改为 `>=` (边界条件)
- [ ] `&&` 改为 `||` (逻辑反转)
- [ ] `+1` 改为 `-1` (off-by-one)
- [ ] `return true` 改为 `return false` (返回值反转)
- [ ] 删除一个 if 分支 (条件删除)
- [ ] 交换两个参数顺序 (参数置换)
### Stryker (JS/TS 突变测试工具)
```bash
# 安装
npx stryker init
# 运行
npx stryker run
# 报告解读
# Mutation Score = killed / (killed + survived)
# 目标: >80% (核心逻辑 >90%)
```
### 突变测试适用场景
- 金额计算、权限判断、状态机转换等高风险逻辑
- 测试覆盖率高但信心不足时
- 不适用于 UI 组件、配置文件等