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

371 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 组件、配置文件等