10 KiB
10 KiB
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
// 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 组件测试
// 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();
});
});
集成测试
// 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
// 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: 重构代码,保持测试通过
测试覆盖率配置
// 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,
},
},
},
});
测试命名规范
// 格式: 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)
并发测试与竞态检测
竞态条件测试模板
// 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();
});
});
文件锁竞态测试
// 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 突变测试工具)
# 安装
npx stryker init
# 运行
npx stryker run
# 报告解读
# Mutation Score = killed / (killed + survived)
# 目标: >80% (核心逻辑 >90%)
突变测试适用场景
- 金额计算、权限判断、状态机转换等高风险逻辑
- 测试覆盖率高但信心不足时
- 不适用于 UI 组件、配置文件等