AI编程助手如何写出高质量测试代码?告别测试剧场反模式
1. 项目概述:告别“测试剧场”,让AI助手写出真正有用的测试
最近在几个项目里用Claude Code和Cursor的AI助手写单元测试,发现一个挺普遍的问题:它生成的测试代码看起来有模有样,覆盖率报告也漂漂亮亮,但真遇到边界情况或者逻辑错误时,这些测试却像个摆设一样毫无反应。后来在社区里看到有人把这种现象叫做“测试剧场”——测试只是在表演,并没有真正起到防护作用。
这个 anti-test-theater 项目就是专门为解决这个问题而生的。它是一个 agent-skills 技能包,安装后能让你的AI编程助手(包括Claude Code、Cursor、Kiro、GitHub Copilot等)学会如何编写高质量的、能真正捕获bug的测试代码。核心思路不是简单地禁止AI写测试,而是通过一套明确的规则和范例,引导AI避开常见的测试反模式,转向基于需求的、有实际价值的测试设计。
我在自己的TypeScript和Python项目里实际用了一段时间,最明显的感受是:测试代码的“智商”明显提高了。以前AI可能会生成那种把实现逻辑在测试里重写一遍的“同义反复”测试,现在它会主动考虑边界条件、异常场景和实际业务需求。对于每天要和测试代码打交道的开发者来说,这能省下大量手动修改和补充测试的时间。
2. “测试剧场”的五大反模式深度解析
2.1 实现逻辑镜像:最隐蔽的无效测试
这是AI生成测试时最容易掉进去的坑,而且乍一看还挺有迷惑性。所谓“实现逻辑镜像”,就是测试代码几乎原封不动地复制了被测试函数的内部逻辑。
// 被测试的函数
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// AI可能生成的“测试剧场”版本
test('calculates total correctly', () => {
const items = [{ price: 10, quantity: 2 }];
// 注意这里:测试里重新实现了完全相同的reduce逻辑
const expected = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
expect(calculateTotal(items)).toBe(expected);
});
这种测试的问题在于,它和实现形成了“循环论证”。如果 calculateTotal 的实现有bug——比如不小心把乘法写成了加法——那么测试里用相同错误逻辑计算出的 expected 值也会是错的,测试依然会通过。这种测试除了让覆盖率数字好看,没有任何实际价值。
正确的做法应该是基于需求来设计测试用例 ,而不是基于实现。对于上面的例子,我们应该思考:“这个函数在业务上应该满足什么要求?”然后针对这些要求设计测试:
test('计算包含多个商品的总价', () => {
const items = [
{ price: 10, quantity: 2 }, // 20
{ price: 5, quantity: 3 }, // 15
{ price: 1, quantity: 10 } // 10
];
// 直接使用预期的业务结果,而不是重新计算
expect(calculateTotal(items)).toBe(45);
});
test('空购物车返回0', () => {
expect(calculateTotal([])).toBe(0);
});
test('正确处理浮点数精度问题', () => {
const items = [
{ price: 0.1, quantity: 1 },
{ price: 0.2, quantity: 1 }
];
// 使用toBeCloseTo而不是toBe,避免0.1+0.2≠0.3的经典问题
expect(calculateTotal(items)).toBeCloseTo(0.3);
});
2.2 过度Mock:让测试失去意义的“皇帝的新衣”
Mock在单元测试中是必要的,但AI经常过度使用。根据项目文档中引用的数据,大约40%由AI生成的Mock对象本身就是有问题的。过度Mock会导致测试不再验证实际的集成行为,而是变成了“Mock配置是否正确”的测试。
// 过度Mock的典型例子
test('getUserData fetches user', async () => {
// Mock了fetch函数
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' })
});
const result = await getUserData(1);
// 这里只是在验证Mock的配置,而不是实际的行为
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
expect(result).toEqual({ id: 1, name: 'Alice' });
});
这个测试的问题在于:如果 getUserData 函数内部把响应解析错了(比如尝试访问 data.user.name 但实际响应是 { name: 'Alice' } ),测试依然会通过,因为Mock返回的正是测试期望的结构。这完全失去了测试的意义。
Mock决策表是 anti-test-theater 提供的一个实用工具 ,它帮你判断什么时候该Mock,什么时候该用真实依赖:
| 依赖类型 | Mock建议 | 理由 | 替代方案 |
|---|---|---|---|
| 外部API调用 | 推荐Mock | 避免测试受网络影响,加速测试执行 | 使用MSW(Mock Service Worker)进行网络层拦截 |
| 数据库操作 | 视情况而定 | 单元测试Mock,集成测试用真实DB | 使用内存数据库(SQLite)或测试容器 |
| 文件系统 | 通常Mock | 避免测试产生副作用 | 使用内存文件系统或临时目录 |
| 当前模块的其他函数 | 不要Mock | 应该测试完整的模块行为 | 如果必须Mock,考虑重构模块职责 |
| 第三方库 | 谨慎Mock | 可能掩盖库的版本兼容问题 | 使用库提供的测试工具或适配器 |
对于上面的 getUserData 例子,更好的测试策略是:
// 方案1:使用MSW进行网络层Mock(更接近真实场景)
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'Alice', email: 'alice@example.com' }));
})
);
test('正确解析用户数据', async () => {
const user = await getUserData(1);
// 验证业务逻辑:我们需要name和email字段
expect(user).toHaveProperty('name', 'Alice');
expect(user).toHaveProperty('email', 'alice@example.com');
});
// 方案2:测试错误处理
test('处理404错误', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(404));
})
);
await expect(getUserData(999)).rejects.toThrow('User not found');
});
2.3 只测“快乐路径”:遗漏了真正的bug藏身地
AI生成的测试往往只覆盖最常规、最顺利的执行路径。但根据经验,80%的bug都发生在边界条件、异常情况和错误处理路径上。
// 只测快乐路径的例子
test('divides numbers correctly', () => {
expect(divide(10, 2)).toBe(5);
expect(divide(9, 3)).toBe(3);
});
这个测试漏掉了所有真正重要的情况:除数为0怎么办?负数怎么处理?浮点数除法呢?大数除法呢?
anti-test-theater 引导AI采用基于需求的四步测试设计法 :
- 识别需求 :这个函数要解决什么问题?业务规则是什么?
- 列出输入空间 :所有可能的输入组合,包括有效和无效的
- 定义边界 :数值边界、集合边界、状态边界
- 设计用例 :为每个重要场景设计具体的测试用例
对于除法函数,完整的测试应该包括:
describe('divide函数', () => {
test('正常整数除法', () => {
expect(divide(10, 2)).toBe(5);
expect(divide(9, 3)).toBe(3);
});
test('浮点数除法', () => {
expect(divide(1, 3)).toBeCloseTo(0.333333, 5);
});
test('除数为0时抛出错误', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
test('处理负数', () => {
expect(divide(-10, 2)).toBe(-5);
expect(divide(10, -2)).toBe(-5);
expect(divide(-10, -2)).toBe(5);
});
test('被除数为0', () => {
expect(divide(0, 5)).toBe(0);
});
test('大数处理', () => {
expect(divide(Number.MAX_SAFE_INTEGER, 2)).toBe(Number.MAX_SAFE_INTEGER / 2);
});
});
2.4 快照滥用:当toMatchSnapshot()成为万能胶
快照测试在UI组件测试中很有用,但AI容易把它当成“一键生成断言”的快捷方式,导致快照测试被滥用。
// 快照滥用的例子
test('UserProfile renders correctly', () => {
const { container } = render(<UserProfile user={mockUser} />);
expect(container).toMatchSnapshot(); // 过于笼统
});
这种测试的问题:
- 脆弱性高 :任何无关的样式改动都会导致快照失败
- 意图不明确 :我们到底在测试什么?是结构?内容?还是样式?
- 维护成本高 :每次UI调整都要更新快照,容易产生“快照疲劳”
正确的快照使用策略应该是针对性的 :
test('显示用户基本信息', () => {
const user = { name: 'Alice', email: 'alice@test.com', role: 'admin' };
render(<UserProfile user={user} />);
// 针对性地测试关键内容
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@test.com')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
test('管理员看到管理按钮', () => {
const user = { role: 'admin' };
render(<UserProfile user={user} />);
expect(screen.getByRole('button', { name: /manage/i })).toBeInTheDocument();
});
test('普通用户看不到管理按钮', () => {
const user = { role: 'user' };
render(<UserProfile user={user} />);
expect(screen.queryByRole('button', { name: /manage/i })).not.toBeInTheDocument();
});
// 如果确实需要用快照,也应该是有针对性的
test('组件结构稳定', () => {
const { container } = render(<UserProfile user={mockUser} />);
// 只快照关键的结构部分,忽略易变的样式类名
const structure = container.querySelector('.user-profile');
expect(structure).toMatchSnapshot();
});
2.5 脆弱的异步测试:setTimeout(2000)和祈祷
异步测试是另一个重灾区。项目文档提到大约30%的AI生成的异步测试是脆弱的(flaky)。脆弱的测试有时通过有时失败,这种测试比没有测试更糟糕——它消耗信任,让人养成忽略测试失败的习惯。
// 脆弱的异步测试
test('loads data and updates state', async () => {
fetchData(); // 触发异步操作
await setTimeout(2000); // 祈祷2秒足够
expect(store.getState().data).toEqual(expectedData);
});
这种测试的问题很明显:如果网络慢一点怎么办?如果后端处理需要2.1秒怎么办?如果测试环境负载高怎么办?
稳健的异步测试应该基于状态或事件 ,而不是基于时间:
// 方案1:等待特定的UI状态
test('数据加载后显示内容', async () => {
render(<DataComponent />);
// 初始应该是加载状态
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待数据加载完成
expect(await screen.findByText('Data loaded')).toBeInTheDocument();
// 验证具体内容
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 方案2:使用Mock控制异步时序
test('处理加载错误', async () => {
// Mock一个会失败的fetch
jest.spyOn(api, 'fetchData').mockRejectedValue(new Error('Network error'));
render(<DataComponent />);
// 应该显示错误信息
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
// 验证重试按钮存在
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
// 方案3:测试异步操作的状态变化
test('跟踪异步操作状态', async () => {
const { result } = renderHook(() => useAsyncData());
// 初始状态
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeNull();
// 触发加载
act(() => {
result.current.loadData();
});
// 应该进入加载状态
expect(result.current.isLoading).toBe(true);
// 等待加载完成
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// 验证数据
expect(result.current.data).toEqual(expect.any(Array));
});
3. 实战:将anti-test-theater集成到开发工作流
3.1 安装与配置详解
安装 anti-test-theater 非常简单,但它支持多种AI开发工具,配置方式略有不同。我以最常用的几个工具为例说明:
# 基础安装(适用于大多数基于Claude Code的工具链)
npx skills add nanami7777777/anti-test-theater
# 如果你使用特定的AI开发环境
# 对于Cursor用户:安装后会在编写测试时自动应用规则
# 对于GitHub Copilot:需要在VSCode设置中启用技能集成
# 对于Aider:在.aider.conf.yml中添加技能配置
安装完成后,你可以在 ~/.claude/skills/anti-test-theater/ 目录下找到所有资源文件。这个目录结构设计得很实用:
~/.claude/skills/anti-test-theater/
├── SKILL.md # 核心规则文件(AI会自动加载)
├── reference/ # 各技术栈的详细参考
│ ├── frontend-testing.md # React/Vue/Playwright最佳实践
│ ├── api-testing.md # API测试模式(含并发测试)
│ ├── go-testing.md # Go语言的表格驱动测试
│ ├── java-testing.md # JUnit 5和Mockito模式
│ └── csharp-testing.md # .NET测试策略
└── scripts/
└── check-test-quality.sh # 测试质量检查脚本
配置技巧 :我发现在项目根目录创建一个 .clauderc 文件可以进一步定制AI的行为:
{
"testing": {
"preferIntegrationOverUnit": true,
"requireEdgeCases": true,
"maxMockDepth": 2,
"testNamingConvention": "should_$behavior_when_$condition"
}
}
3.2 测试质量检查脚本的深度使用
项目提供的 check-test-quality.sh 脚本是个宝藏工具,但很多人只是简单地运行它。其实它可以做很多定制化的检查:
# 基本用法:扫描整个src目录
bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/
# 只检查特定的测试文件
bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/components/__tests__/
# 生成详细的HTML报告(需要安装jq)
bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/ --format html > test-quality-report.html
# 集成到CI/CD流水线中,设置质量阈值
bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/ --min-score 80
这个脚本会检查以下反模式:
- 同义反复测试 :测试中重新实现了业务逻辑
- 过度Mock :Mock了不应该Mock的依赖
- 缺少边界测试 :只有正常路径的测试
- 脆弱的异步 :使用固定时间等待
- 模糊的快照 :对整个组件使用快照测试
我在团队中配置了Git预提交钩子,在提交前自动运行这个检查:
#!/bin/bash
# .git/hooks/pre-commit
# 运行测试质量检查
TEST_QUALITY_REPORT=$(bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/ --format json)
# 解析JSON输出,检查分数
SCORE=$(echo "$TEST_QUALITY_REPORT" | jq '.score')
if [ "$SCORE" -lt 70 ]; then
echo "❌ 测试质量分数过低: $SCORE/100"
echo "请修复以下问题:"
echo "$TEST_QUALITY_REPORT" | jq -r '.issues[] | "- \(.file):\(.line) \(.type): \(.message)"'
exit 1
fi
3.3 不同技术栈的适配策略
anti-test-theater 的reference目录包含了针对不同技术栈的详细指南,但你需要知道如何有效地利用它们。
对于前端项目(React/Vue) :
// 参考frontend-testing.md的最佳实践
// 1. 测试用户交互,而不是实现细节
test('点击按钮提交表单', async () => {
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /submit/i });
// 错误的做法:测试内部状态
// expect(formInstance.state.isSubmitting).toBe(true);
// 正确的做法:测试用户可见的行为
fireEvent.click(submitButton);
expect(await screen.findByText('Submitting...')).toBeInTheDocument();
});
// 2. 使用Testing Library的查询优先级
// 从用户角度查询,而不是实现角度
const goodQueries = [
'getByRole', // 最推荐:按角色查询(按钮、链接等)
'getByLabelText', // 表单标签
'getByPlaceholderText', // 占位符
'getByText', // 文本内容
'getByDisplayValue', // 显示的值
];
const badQueries = [
'getByTestId', // 最后的选择:测试ID
'container.querySelector', // 避免:太依赖实现
];
对于API测试 :
# 参考api-testing.md的Python示例
# 1. 测试完整的请求-响应周期
def test_create_user_validation():
# 错误的做法:只测试序列化器
# serializer = UserSerializer(data={'email': 'invalid'})
# assert not serializer.is_valid()
# 正确的做法:测试完整的API端点
response = client.post('/api/users/', {'email': 'invalid'})
assert response.status_code == 400
assert 'email' in response.json()
# 2. 并发测试(经常被忽略)
def test_concurrent_user_creation():
import threading
def create_user():
response = client.post('/api/users/', {'email': f'test{threading.get_ident()}@test.com'})
return response.status_code
# 模拟并发创建
threads = [threading.Thread(target=create_user) for _ in range(10)]
[t.start() for t in threads]
[t.join() for t in threads]
# 验证没有重复用户被创建
user_count = User.objects.filter(email__startswith='test').count()
assert user_count <= 10 # 应该正好是10,但<=10可以检测重复问题
对于Go项目 :
// 参考go-testing.md的表格驱动测试模式
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
expectError bool
}{
{
name: "正常除法",
a: 10,
b: 2,
want: 5,
},
{
name: "除数为零",
a: 10,
b: 0,
expectError: true,
},
{
name: "浮点数除法",
a: 1,
b: 3,
want: 0.3333333333333333,
},
{
name: "负数除法",
a: -10,
b: 2,
want: -5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.expectError {
if err == nil {
t.Errorf("Divide(%v, %v) 期望错误,但得到了 %v", tt.a, tt.b, got)
}
return
}
if err != nil {
t.Errorf("Divide(%v, %v) 发生意外错误: %v", tt.a, tt.b, err)
return
}
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Divide(%v, %v) = %v, 期望 %v", tt.a, tt.b, got, tt.want)
}
})
}
}
4. 高级技巧:让AI写出生产级测试代码
4.1 测试命名规范的实际应用
好的测试名应该让人一眼就知道它在测试什么、在什么条件下测试、期望什么结果。 anti-test-theater 推荐了一套命名规范,但需要根据实际情况调整:
// 不好的命名
test('testCalculateTotal', () => {}) // 冗余的"test"前缀
test('calculateTotal', () => {}) // 只是重复函数名
test('works', () => {}) // 太模糊
// 好的命名模式
describe('calculateTotal函数', () => {
// 模式1:should_$behavior_when_$condition
test('应该返回0当购物车为空时', () => {
expect(calculateTotal([])).toBe(0);
});
// 模式2:given_$condition_when_$action_then_$result
test('给定多个商品时当计算总价那么返回正确总和', () => {
const items = [{ price: 10, qty: 2 }, { price: 5, qty: 3 }];
expect(calculateTotal(items)).toBe(35);
});
// 模式3:针对特定场景
test('处理浮点数精度问题', () => {
const items = [{ price: 0.1, qty: 1 }, { price: 0.2, qty: 1 }];
expect(calculateTotal(items)).toBeCloseTo(0.3);
});
// 模式4:错误场景
test('商品数量为负数时抛出错误', () => {
const items = [{ price: 10, qty: -1 }];
expect(() => calculateTotal(items)).toThrow('数量不能为负数');
});
});
在实际项目中,我建议团队统一使用一种命名模式。我个人偏好第一种(should_$behavior_when_$condition),因为它读起来最自然,也最容易让AI理解。
4.2 测试粒度的智能选择
什么时候写单元测试?什么时候写集成测试?什么时候写E2E测试?AI往往分不清这些界限。 anti-test-theater 提供了一个实用的决策框架:
| 测试类型 | 适合场景 | 执行速度 | 可靠性 | 维护成本 | AI生成建议 |
|---|---|---|---|---|---|
| 单元测试 | 纯函数、工具函数、简单组件 | 快(<100ms) | 高 | 低 | 推荐AI生成 |
| 集成测试 | 模块间交互、API端点、数据库操作 | 中(100ms-2s) | 中 | 中 | 部分AI生成,需人工审查 |
| E2E测试 | 完整用户流程、跨系统交互 | 慢(>2s) | 低 | 高 | 不推荐AI生成 |
具体决策流程 :
- 如果是纯函数 (相同输入总是得到相同输出,无副作用)→ 单元测试
- 如果涉及外部依赖 (数据库、API、文件系统)→ 集成测试
- 如果涉及多个系统或完整用户流程 → E2E测试
- 如果测试需要浏览器环境 → 组件测试或E2E测试
// 单元测试示例:纯函数
describe('formatDate函数', () => {
test('应该将ISO字符串格式化为本地日期', () => {
expect(formatDate('2024-01-15T10:30:00Z')).toBe('2024年1月15日');
});
});
// 集成测试示例:涉及数据库
describe('UserService', () => {
let db: TestDatabase;
beforeEach(async () => {
db = await createTestDatabase(); // 使用测试数据库
});
test('创建用户并保存到数据库', async () => {
const service = new UserService(db);
const user = await service.createUser({ name: 'Alice', email: 'alice@test.com' });
// 验证数据库中的实际数据
const dbUser = await db.users.findOne({ id: user.id });
expect(dbUser).toEqual(expect.objectContaining({
name: 'Alice',
email: 'alice@test.com'
}));
});
});
// E2E测试示例:完整流程(通常手动编写)
describe('用户注册流程', () => {
test('新用户可以完成注册并登录', async () => {
// 1. 访问注册页面
await page.goto('https://app.example.com/register');
// 2. 填写注册表单
await page.fill('#email', 'newuser@example.com');
await page.fill('#password', 'SecurePass123!');
await page.click('button[type="submit"]');
// 3. 验证注册成功
await expect(page).toHaveURL(/dashboard/);
await expect(page.locator('.welcome-message')).toContainText('欢迎');
});
});
4.3 测试数据管理的艺术
AI生成的测试经常硬编码测试数据,这会导致两个问题:1) 测试数据与生产数据差异太大;2) 测试数据难以维护。
// 不好的做法:硬编码所有数据
test('计算订单总价', () => {
const order = {
id: 1,
items: [
{ id: 101, name: '商品A', price: 100, quantity: 2 },
{ id: 102, name: '商品B', price: 50, quantity: 3 },
],
customer: { id: 1001, name: '张三' },
// ... 更多硬编码字段
};
expect(calculateOrderTotal(order)).toBe(350);
});
// 好的做法:使用工厂函数和构建器模式
import { OrderBuilder } from '../test/builders/OrderBuilder';
test('计算订单总价', () => {
// 使用构建器创建测试订单
const order = new OrderBuilder()
.withItem({ price: 100, quantity: 2 })
.withItem({ price: 50, quantity: 3 })
.build();
expect(calculateOrderTotal(order)).toBe(350);
});
// 更好的做法:分离测试数据和测试逻辑
describe('订单计算', () => {
const testCases = [
{
name: '多个商品',
items: [{ price: 100, qty: 2 }, { price: 50, qty: 3 }],
expected: 350,
},
{
name: '空订单',
items: [],
expected: 0,
},
{
name: '折扣商品',
items: [{ price: 100, qty: 1, discount: 20 }],
expected: 80,
},
];
testCases.forEach(({ name, items, expected }) => {
test(name, () => {
const order = new OrderBuilder().withItems(items).build();
expect(calculateOrderTotal(order)).toBe(expected);
});
});
});
测试数据工厂的实现示例 :
// test/factories/UserFactory.ts
export class UserFactory {
static create(overrides: Partial<User> = {}): User {
const defaults: User = {
id: faker.datatype.uuid(),
name: faker.name.fullName(),
email: faker.internet.email(),
age: faker.datatype.number({ min: 18, max: 65 }),
createdAt: new Date(),
isActive: true,
};
return { ...defaults, ...overrides };
}
static createList(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => this.create(overrides));
}
static createInactive(): User {
return this.create({ isActive: false });
}
static createUnderage(): User {
return this.create({ age: faker.datatype.number({ min: 13, max: 17 }) });
}
}
// 在测试中使用
test('只返回活跃用户', () => {
const users = [
UserFactory.create({ isActive: true }),
UserFactory.create({ isActive: false }), // 应该被过滤掉
UserFactory.create({ isActive: true }),
];
const activeUsers = filterActiveUsers(users);
expect(activeUsers).toHaveLength(2);
expect(activeUsers.every(u => u.isActive)).toBe(true);
});
4.4 性能测试与基准测试
大多数AI生成的测试只关注正确性,忽略了性能。但在实际项目中,性能回归是常见问题。
// 性能测试示例
describe('searchProducts性能', () => {
const largeProductList = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `产品${i}`,
category: i % 10 === 0 ? '特价' : '常规',
price: 10 + (i % 100),
}));
test('搜索响应时间应在100ms内', () => {
const start = performance.now();
const results = searchProducts(largeProductList, '特价');
const duration = performance.now() - start;
expect(duration).toBeLessThan(100);
expect(results).toHaveLength(1000); // 10000个产品中10%是特价
});
test('内存使用不应线性增长', () => {
const initialMemory = process.memoryUsage().heapUsed;
// 多次搜索,内存使用应该稳定
for (let i = 0; i < 100; i++) {
searchProducts(largeProductList, `产品${i}`);
}
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = finalMemory - initialMemory;
// 内存增长应该小于1MB
expect(memoryIncrease).toBeLessThan(1024 * 1024);
});
});
// 基准测试(使用Benchmark.js)
import Benchmark from 'benchmark';
describe('排序算法性能比较', () => {
test('快速排序 vs 归并排序', () => {
const suite = new Benchmark.Suite();
const testArray = Array.from({ length: 10000 }, () => Math.random());
suite
.add('快速排序', () => {
quickSort([...testArray]);
})
.add('归并排序', () => {
mergeSort([...testArray]);
})
.on('cycle', (event: any) => {
console.log(String(event.target));
})
.on('complete', function (this: any) {
console.log('最快的算法是: ' + this.filter('fastest').map('name'));
})
.run({ async: false });
});
});
5. 常见问题排查与实战经验
5.1 AI生成的测试过于简单怎么办?
这是最常见的问题。AI倾向于生成最简化的测试用例,忽略了边界情况和错误处理。
解决方案 :在提示词中明确要求具体的测试场景:
请为calculateTotal函数编写完整的测试套件,包括:
1. 正常情况:多个商品的计算
2. 边界情况:空数组、单个商品、数量为0的商品
3. 错误情况:价格或数量为负数、非数字输入
4. 特殊数值:浮点数精度问题、极大数值
5. 性能考虑:大量商品时的计算效率
实际案例 :我曾经让AI测试一个价格计算函数,它最初只生成了两个测试用例。我按照上面的模板重新提示后,它生成了12个测试用例,包括了我没想到的“商品数量为小数”的情况。
5.2 测试与实现耦合太紧怎么办?
AI有时会生成过度依赖实现细节的测试,比如测试私有方法、测试内部状态变化。
// 不好的测试:测试实现细节
test('内部状态更新', () => {
const instance = new ShoppingCart();
instance.addItem({ id: 1, price: 10 }); // 公有方法
expect(instance.items.length).toBe(1); // 测试内部状态
expect(instance.items[0].id).toBe(1); // 测试内部数据结构
});
// 好的测试:测试公有行为
test('添加商品后可以获取总价', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10, quantity: 2 });
expect(cart.getTotal()).toBe(20); // 只测试公有接口
});
test('添加重复商品时合并数量', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10, quantity: 1 });
cart.addItem({ id: 1, price: 10, quantity: 2 });
expect(cart.getTotal()).toBe(30); // 数量合并为3
});
经验法则 :只测试类的公有接口。如果发现需要测试私有方法,通常意味着这个类需要重构——要么将私有方法提取为公有工具函数,要么将类拆分成更小的单元。
5.3 异步测试难以稳定运行怎么办?
脆弱的异步测试是团队的一大痛点。除了前面提到的避免固定超时的方法,还有一些高级技巧:
// 技巧1:使用可配置的超时和重试
const withRetry = async <T>(
fn: () => Promise<T>,
options = { retries: 3, delay: 100 }
): Promise<T> => {
let lastError: Error;
for (let i = 0; i < options.retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < options.retries - 1) {
await new Promise(resolve => setTimeout(resolve, options.delay));
}
}
}
throw lastError;
};
test('不稳定的API调用', async () => {
await withRetry(async () => {
const result = await fetchUnstableApi();
expect(result.status).toBe('success');
}, { retries: 5, delay: 200 });
});
// 技巧2:使用TestContainers进行集成测试
import { GenericContainer, StartedTestContainer } from 'testcontainers';
describe('数据库集成测试', () => {
let container: StartedTestContainer;
let dbClient: DbClient;
beforeAll(async () => {
// 启动一个真实的PostgreSQL容器
container = await new GenericContainer('postgres:15')
.withExposedPorts(5432)
.withEnvironment({
POSTGRES_USER: 'test',
POSTGRES_PASSWORD: 'test',
POSTGRES_DB: 'test',
})
.start();
const port = container.getMappedPort(5432);
dbClient = new DbClient({
host: 'localhost',
port,
user: 'test',
password: 'test',
database: 'test',
});
}, 30000); // 设置更长的超时
afterAll(async () => {
await dbClient.close();
await container.stop();
});
test('真实的数据库操作', async () => {
await dbClient.query('INSERT INTO users (name) VALUES ($1)', ['Alice']);
const result = await dbClient.query('SELECT * FROM users');
expect(result.rows).toHaveLength(1);
expect(result.rows[0].name).toBe('Alice');
});
});
5.4 测试维护成本太高怎么办?
随着项目演进,测试也需要维护。AI可以帮助减少维护成本:
// 使用参数化测试减少重复
describe.each([
{ input: [], expected: 0, description: '空数组' },
{ input: [{ price: 10, qty: 1 }], expected: 10, description: '单个商品' },
{ input: [{ price: 10, qty: 2 }, { price: 5, qty: 3 }], expected: 35, description: '多个商品' },
{ input: [{ price: 0.1, qty: 1 }, { price: 0.2, qty: 1 }], expected: 0.3, description: '浮点数' },
])('calculateTotal - $description', ({ input, expected }) => {
test(`返回${expected}`, () => {
expect(calculateTotal(input)).toBeCloseTo(expected);
});
});
// 使用自定义匹配器提高可读性
expect.extend({
toBeValidEmail(received) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
return {
message: () => `期望${received}是一个有效的邮箱地址`,
pass,
};
},
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
return {
message: () => `期望${received}在${floor}和${ceiling}之间`,
pass,
};
},
});
// 在测试中使用
test('验证用户邮箱', () => {
const user = { email: 'user@example.com' };
expect(user.email).toBeValidEmail();
});
test('年龄在有效范围内', () => {
const user = { age: 25 };
expect(user.age).toBeWithinRange(18, 100);
});
5.5 测试覆盖率高但质量低怎么办?
这是“测试剧场”的典型症状:覆盖率报告显示90%+,但bug依然频发。
诊断方法 :
- 检查行覆盖率 vs 分支覆盖率 :行覆盖率90%但分支覆盖率只有50%,说明很多条件分支没测试
- 检查突变测试结果 :使用Stryker等突变测试工具,看测试能否杀死突变体
- 手动代码审查 :重点审查复杂逻辑、边界条件、错误处理
改进策略 :
// 使用突变测试工具
// 安装:npm install --save-dev @stryker-mutator/core
// 配置:stryker.config.json
{
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"testRunner": "jest",
"coverageAnalysis": "perTest"
}
// 运行突变测试后,查看哪些突变体没被杀死
// 这能揭示测试的薄弱环节
// 针对性地补充测试
describe('复杂的条件逻辑', () => {
// 原始函数
function calculateDiscount(price: number, userType: string, isPremium: boolean): number {
if (price > 1000) {
if (userType === 'vip') {
return isPremium ? 0.2 : 0.15;
} else {
return 0.1;
}
} else if (price > 500) {
return 0.05;
} else {
return 0;
}
}
// 测试所有分支
test.each([
{ price: 1200, userType: 'vip', isPremium: true, expected: 0.2 },
{ price: 1200, userType: 'vip', isPremium: false, expected: 0.15 },
{ price: 1200, userType: 'regular', isPremium: true, expected: 0.1 },
{ price: 1200, userType: 'regular', isPremium: false, expected: 0.1 },
{ price: 600, userType: 'vip', isPremium: true, expected: 0.05 },
{ price: 600, userType: 'regular', isPremium: false, expected: 0.05 },
{ price: 300, userType: 'vip', isPremium: true, expected: 0 },
{ price: 300, userType: 'regular', isPremium: false, expected: 0 },
])('价格$price, 用户$userType, 高级$isPremium => 折扣$expected', ({ price, userType, isPremium, expected }) => {
expect(calculateDiscount(price, userType, isPremium)).toBe(expected);
});
});
5.6 测试代码本身难以维护怎么办?
测试代码也是代码,也需要遵循良好的编码实践。
测试代码的SOLID原则 :
- 单一职责 :每个测试只测一件事
- 开放封闭 :易于添加新测试,无需修改现有测试
- 里氏替换 :测试基类的地方,子类测试也能通过
- 接口隔离 :测试只依赖它们需要的部分
- 依赖倒置 :依赖注入,便于Mock
// 不好的测试:一个测试做多件事
test('用户注册流程', () => {
// 测试验证逻辑
expect(validateEmail('invalid')).toBe(false);
expect(validateEmail('test@example.com')).toBe(true);
// 测试数据库操作
const user = createUser({ email: 'test@example.com' });
expect(user.id).toBeDefined();
// 测试邮件发送
expect(sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
// 太多断言,一个失败整个测试失败
});
// 好的测试:拆分关注点
describe('用户注册', () => {
describe('邮箱验证', () => {
test('无效邮箱返回false', () => {
expect(validateEmail('invalid')).toBe(false);
});
test('有效邮箱返回true', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
});
describe('用户创建', () => {
test('创建用户并返回ID', () => {
const user = createUser({ email: 'test@example.com' });
expect(user.id).toBeDefined();
});
});
describe('欢迎邮件', () => {
test('新用户注册后发送欢迎邮件', () => {
createUser({ email: 'test@example.com' });
expect(sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
});
});
});
// 使用测试夹具减少重复
describe('购物车', () => {
let cart: ShoppingCart;
let mockProduct: Product;
beforeEach(() => {
// 每个测试前重新初始化
cart = new ShoppingCart();
mockProduct = {
id: 'prod-1',
name: '测试商品',
price: 100,
stock: 10,
};
});
test('添加商品', () => {
cart.addItem(mockProduct, 2);
expect(cart.getTotal()).toBe(200);
});
test('移除商品', () => {
cart.addItem(mockProduct, 2);
cart.removeItem('prod-1');
expect(cart.getTotal()).toBe(0);
});
// 每个测试都是独立的,不会相互影响
});
6. 测试策略与团队协作
6.1 制定团队的测试规范
引入 anti-test-theater 后,最好制定团队的测试编写规范。这里是我在团队中推行的一套规范:
# 测试编写规范
## 1. 测试结构
- 使用 `describe` 组织相关测试
- 每个 `describe` 对应一个被测单元(函数、类、模块)
- 使用 `beforeEach` 而不是 `beforeAll`,确保测试隔离
- 测试名使用 `should_$behavior_when_$condition` 格式
## 2. 测试内容
- 每个测试只验证一个行为
- 包含正常路径、边界情况和错误处理
- 避免测试实现细节,只测试公有接口
- 使用真实数据,避免魔法数字
## 3. Mock策略
- 只Mock外部依赖(API、数据库、文件系统)
- 使用MSW Mock网络请求,而不是直接Mock fetch
- 对于数据库,使用测试数据库或内存数据库
- 避免过度Mock,保持测试的真实性
## 4. 异步测试
- 使用 `findBy*` 查询等待元素出现
- 避免固定时间的 `setTimeout`
- 使用 `waitFor` 等待条件满足
- 设置合理的超时时间
## 5. 测试数据
- 使用工厂函数创建测试数据
- 对于复杂对象,使用构建器模式
- 将测试数据定义在测试文件顶部或单独的文件中
- 避免硬编码,使用有意义的变量名
## 6. 断言
- 每个测试至少有一个断言
- 使用具体的断言,避免模糊的 `toBeTruthy`
- 对于数组或对象,使用 `toHaveLength`、`toContain`、`toMatchObject`
- 错误断言使用 `toThrow` 和 `toThrowError`
## 7. 覆盖率目标
- 行覆盖率:≥80%
- 分支覆盖率:≥70%
- 函数覆盖率:≥90%
- 突变测试得分:≥80%
6.2 在CI/CD中集成质量检查
将测试质量检查集成到CI/CD流水线中,可以防止低质量测试进入代码库:
# .github/workflows/test-quality.yml
name: Test Quality Check
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
test-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install anti-test-theater
run: npx skills add nanami7777777/anti-test-theater
- name: Run test quality check
run: |
bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/ \
--min-score 75 \
--format json \
--output test-quality-report.json
- name: Upload test quality report
uses: actions/upload-artifact@v3
if: always()
with:
name: test-quality-report
path: test-quality-report.json
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const report = require('./test-quality-report.json');
const { score, issues } = report;
let comment = `## 测试质量报告\n\n`;
comment += `**得分**: ${score}/100\n\n`;
if (score < 75) {
comment += `❌ **未通过质量检查** (最低要求: 75)\n\n`;
} else {
comment += `✅ **通过质量检查**\n\n`;
}
if (issues.length > 0) {
comment += `### 发现的问题:\n\n`;
issues.forEach(issue => {
comment += `- **${issue.file}:${issue.line}** - ${issue.type}: ${issue.message}\n`;
});
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
6.3 测试代码审查清单
在代码审查时,除了业务逻辑,也要审查测试代码的质量。这是我们的测试代码审查清单:
## 测试代码审查清单
### 1. 测试设计
- [ ] 测试是否覆盖了正常路径?
- [ ] 测试是否覆盖了边界情况?
- [ ] 测试是否覆盖了错误处理?
- [ ] 测试是否避免了实现细节?
- [ ] 测试是否独立(不依赖其他测试)?
### 2. 测试可读性
- [ ] 测试名是否清晰描述了测试内容?
- [ ] 测试数据是否易于理解?
- [ ] 断言是否明确表达了期望?
- [ ] 是否有过多的设置代码?
- [ ] 是否使用了适当的帮助函数?
### 3. 测试可靠性
- [ ] 测试是否稳定(不脆弱)?
- [ ] 异步测试是否有适当的等待机制?
- [ ] 是否避免了固定时间的等待?
- [ ] Mock是否适当(不过度也不过少)?
- [ ] 测试是否清理了资源?
### 4. 测试维护性
- [ ] 测试是否遵循DRY原则?
- [ ] 是否有重复的测试代码?
- [ ] 测试数据是否易于修改?
- [ ] 测试是否易于理解?
- [ ] 测试失败时的错误信息是否有帮助?
### 5. 测试性能
- [ ] 测试运行速度是否合理?
- [ ] 是否有不必要的慢操作?
- [ ] 是否使用了适当的测试替身?
- [ ] 测试是否并行安全?
6.4 测试代码的重构策略
当测试代码变得难以维护时,需要重构。以下是一些常见的测试代码坏味道和重构方法:
// 坏味道1:重复的设置代码
// 重构前
test('测试A', () => {
const db = createTestDatabase();
const service = new UserService(db);
const repo = new UserRepository(db);
// ... 20行设置代码
// 实际测试只有2行
});
test('测试B', () => {
const db = createTestDatabase();
const service = new UserService(db);
const repo = new UserRepository(db);
// ... 相同的20行设置代码
// 另一个测试
});
// 重构后:使用beforeEach和工厂函数
describe('UserService', () => {
let db: TestDatabase;
let service: UserService;
let repo: UserRepository;
beforeEach(() => {
db = createTestDatabase();
service = new UserService(db);
repo = new UserRepository(db);
});
test('测试A', () => {
// 直接开始测试
});
test('测试B', () => {
// 另一个测试
});
});
// 坏味道2:过于复杂的测试数据
// 重构前
test('复杂业务逻辑', () => {
const order = {
id: 1,
customer: {
id: 1001,
name: '张三',
email: 'zhangsan@example.com',
address: {
street: '人民路',
city: '北京',
zipCode: '100000'
}
},
items: [
{
id: 101,
name: '商品A',
price: 100,
quantity: 2,
sku: 'SKU001',
category: '电子产品',
// ... 更多字段
}
],
// ... 更多嵌套
};
// 测试代码
});
// 重构后:使用构建器模式
test('复杂业务逻辑', () => {
const order = new OrderBuilder()
.withCustomer(
new CustomerBuilder()
.withName('张三')
.withEmail('zhangsan@example.com')
.withAddress('北京', '人民路', '100000')
.build()
)
.withItem(
new ItemBuilder()
.withName('商品A')
.withPrice(100)
.withQuantity(2)
.withCategory('电子产品')
.build()
)
.build();
// 测试代码
});
// 坏味道3:测试逻辑与断言混合
// 重构前
test('计算订单折扣', () => {
const order = createTestOrder();
const discount = calculateDiscount(order);
// 复杂的断言逻辑
if (order.customer.isVIP) {
expect(discount).toBeGreaterThan(0.1);
if (order.total > 1000) {
expect(discount).toBe(0.2);
} else {
expect(discount).toBe(0.15);
}
} else {
expect(discount).toBe(0);
}
});
// 重构后:使用参数化测试
describe.each([
{ isVIP: true, total: 1200, expectedDiscount: 0.2 },
{ isVIP: true, total: 800, expectedDiscount: 0.15 },
{ isVIP: false, total: 1200, expectedDiscount: 0 },
{ isVIP: false, total: 800, expectedDiscount: 0 },
])('计算订单折扣 - VIP:$isVIP, 总价:$total', ({ isVIP, total, expectedDiscount }) => {
test(`折扣应为${expectedDiscount}`, () => {
const order = createTestOrder({ customer: { isVIP }, total });
const discount = calculateDiscount(order);
expect(discount).toBe(expectedDiscount);
});
});
7. 测试驱动开发(TDD)与AI的结合
7.1 使用AI加速TDD流程
传统的TDD流程是:红(写失败测试)→ 绿(写最少代码使测试通过)→ 重构。AI可以在这个流程的每个阶段提供帮助:
// 阶段1:红 - AI帮助编写测试
// 提示词:为UserService的createUser方法编写测试,要求:
// 1. 测试正常创建用户
// 2. 测试邮箱重复的情况
// 3. 测试无效邮箱的情况
// 4. 测试密码强度不足的情况
// AI生成的测试骨架
describe('UserService.createUser', () => {
let service: UserService;
let mockRepo: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepo = {
findByEmail: jest.fn(),
save: jest.fn(),
};
service = new UserService(mockRepo);
});
test('创建新用户成功', async () => {
// 待实现
});
test('邮箱已存在时抛出错误', async () => {
// 待实现
});
test('无效邮箱格式时抛出错误', async () => {
// 待实现
});
test('密码强度不足时抛出错误', async () => {
// 待实现
});
});
// 阶段2:绿 - AI帮助实现功能
// 提示词:实现UserService.createUser方法,满足以下测试要求:
// 1. 邮箱必须符合格式
// 2. 密码至少8位,包含大小写字母和数字
// 3. 邮箱不能重复
// 4. 成功创建后返回用户ID
// AI生成的实现
class UserService {
constructor(private userRepo: UserRepository) {}
async createUser(data: CreateUserDto): Promise<string> {
// 验证邮箱格式
if (!this.isValidEmail(data.email)) {
throw new Error('无效的邮箱格式');
}
// 验证密码强度
if (!this.isStrongPassword(data.password)) {
throw new Error('密码强度不足');
}
// 检查邮箱是否已存在
const existing = await this.userRepo.findByEmail(data.email);
if (existing) {
throw new Error('邮箱已注册');
}
// 创建用户
const user = new User({
email: data.email,
passwordHash: await this.hashPassword(data.password),
createdAt: new Date(),
});
const saved = await this.userRepo.save(user);
return saved.id;
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
private isStrongPassword(password: string): boolean {
return password.length >= 8 &&
/[a-z]/.test(password) &&
/[A-Z]/.test(password) &&
/\d/.test(password);
}
private async hashPassword(password: string): Promise<string> {
// 实际项目中应该使用bcrypt等库
return Buffer.from(password).toString('base64');
}
}
// 阶段3:重构 - AI帮助改进代码
// 提示词:重构UserService,将邮箱验证和密码验证提取到独立的类中
// AI建议的重构
class EmailValidator {
static isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
static normalize(email: string): string {
return email.trim().toLowerCase();
}
}
class PasswordValidator {
static isStrong(password: string): boolean {
return password.length >= 8 &&
/[a-z]/.test(password) &&
/[A-Z]/.test(password) &&
/\d/.test(password);
}
static getStrengthScore(password: string): number {
let score = 0;
if (password.length >= 8) score++;
if (/[a-z]/.test(password)) score++;
if (/[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
return score;
}
}
// 重构后的UserService
class UserService {
constructor(private userRepo: UserRepository) {}
async createUser(data: CreateUserDto): Promise<string> {
if (!EmailValidator.isValid(data.email)) {
throw new Error('无效的邮箱格式');
}
if (!PasswordValidator.isStrong(data.password)) {
throw new Error('密码强度不足');
}
const normalizedEmail = EmailValidator.normalize(data.email);
const existing = await this.userRepo.findByEmail(normalizedEmail);
if (existing) {
throw new Error('邮箱已注册');
}
const user = new User({
email: normalizedEmail,
passwordHash: await this.hashPassword(data.password),
createdAt: new Date(),
});
const saved = await this.userRepo.save(user);
return saved.id;
}
private async hashPassword(password: string): Promise<string> {
return Buffer.from(password).toString('base64');
}
}
7.2 测试作为文档的实践
好的测试本身就是最好的文档。AI可以帮助我们编写更具可读性的测试,这些测试可以作为代码的使用示例:
// 传统的测试(只关注正确性)
test('addUser adds user to repository', () => {
const repo = new UserRepository();
const service = new UserService(repo);
service.addUser('Alice', 'alice@example.com');
expect(repo.users).toHaveLength(1);
});
// 作为文档的测试(展示如何使用API)
describe('UserService API示例', () => {
test('如何创建新用户', async () => {
// 1. 创建服务实例
const userService = new UserService(new InMemoryUserRepository());
// 2. 调用createUser方法
const userId = await userService.createUser({
name: 'Alice',
email: 'alice@example.com',
password: 'SecurePass123'
});
// 3. 验证用户已创建
expect(userId).toBeDefined();
// 4. 可以获取用户信息
const user = await userService.getUser(userId);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
test('如何处理重复邮箱', async () => {
const userService = new UserService(new InMemoryUserRepository());
// 第一次创建成功
await userService.createUser({
name: 'Alice',
email: 'alice@example.com',
password: 'SecurePass123'
});
// 第二次使用相同邮箱会失败
await expect(
userService.createUser({
name: 'Alice2',
email: 'alice@example.com', // 重复邮箱
password: 'AnotherPass456'
})
).rejects.toThrow('邮箱已注册');
});
test('如何验证用户密码', async () => {
const userService = new UserService(new InMemoryUserRepository());
// 创建用户
const userId = await userService.createUser({
name: 'Alice',
email: 'alice@example.com',
password: 'MySecretPassword'
});
// 使用正确密码验证
const isValid = await userService.verifyPassword(userId, 'MySecretPassword');
expect(isValid).toBe(true);
// 使用错误密码验证
const isInvalid = await userService.verifyPassword(userId, 'WrongPassword');
expect(isInvalid).toBe(false);
});
test('如何更新用户信息', async () => {
const userService = new UserService(new InMemoryUserRepository());
const userId = await userService.createUser({
name: 'Alice',
email: 'alice@example.com',
password: 'password123'
});
// 更新用户姓名
await userService.updateUser(userId, { name: 'Alice Smith' });
const updatedUser = await userService.getUser(userId);
expect(updatedUser.name).toBe('Alice Smith');
expect(updatedUser.email).toBe('alice@example.com'); // 邮箱不变
});
});
7.3 测试生成与维护的自动化
随着项目规模增长,手动维护测试变得越来越困难。可以建立一些自动化流程:
// 自动化测试生成脚本
// scripts/generate-tests.js
const fs = require('fs');
const path = require('path');
const { parse } = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
function generateTestsForFile(filePath) {
const code = fs.readFileSync(filePath, 'utf-8');
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
});
const tests = [];
traverse(ast, {
FunctionDeclaration(path) {
const funcName = path.node.id.name;
const params = path.node.params.map(p => p.name);
// 跳过私有函数(以下划线开头)
if (funcName.startsWith('_')) return;
// 生成测试模板
const testCode = `
describe('${funcName}', () => {
test('应该处理正常情况', () => {
// TODO: 添加测试逻辑
});
test('应该处理边界情况', () => {
// TODO: 添加边界测试
});
test('应该处理错误情况', () => {
// TODO: 添加错误处理测试
});
});
`;
tests.push({
function: funcName,
testCode: testCode.trim()
});
},
ClassDeclaration(path) {
const className = path.node.id.name;
// 收集公有方法
const publicMethods = [];
path.traverse({
ClassMethod(classMethodPath) {
const methodName = classMethodPath.node.key.name;
// 跳过私有方法和构造函数
if (methodName.startsWith('_') || methodName === 'constructor') return;
publicMethods.push(methodName);
}
});
if (publicMethods.length > 0) {
const testCode = `
describe('${className}', () => {
${publicMethods.map(method => `
describe('${method}', () => {
test('应该正确执行', () => {
// TODO: 测试${method}方法
});
});
`).join('\n')}
});
`;
tests.push({
class: className,
testCode: testCode.trim()
});
}
}
});
return tests;
}
// 使用示例
const tests = generateTestsForFile('./src/utils/math.ts');
console.log('生成的测试模板:');
tests.forEach((test, index) => {
console.log(`\n// 测试 ${index + 1}`);
console.log(test.testCode);
});
// 测试覆盖率监控脚本
// scripts/monitor-coverage.js
const { execSync } = require('child_process');
const fs = require('fs');
function getCoverageReport() {
try {
// 运行测试并生成覆盖率报告
execSync('npm test -- --coverage', { stdio: 'inherit' });
// 读取覆盖率报告
const coverageSummary = JSON.parse(
fs.readFileSync('./coverage/coverage-summary.json', 'utf-8')
);
const totals = coverageSummary.total;
console.log('\n=== 测试覆盖率报告 ===');
console.log(`行覆盖率: ${totals.lines.pct}%`);
console.log(`语句覆盖率: ${totals.statements.pct}%`);
console.log(`分支覆盖率: ${totals.branches.pct}%`);
console.log(`函数覆盖率: ${totals.functions.pct}%`);
// 检查是否达标
const thresholds = {
lines: 80,
statements: 80,
branches: 70,
functions: 90
};
let passed = true;
for (const [metric, threshold] of Object.entries(thresholds)) {
if (totals[metric].pct < threshold) {
console.log(`❌ ${metric}覆盖率 ${totals[metric].pct}% < ${threshold}%`);
passed = false;
} else {
console.log(`✅ ${metric}覆盖率 ${totals[metric].pct}% >= ${threshold}%`);
}
}
// 找出覆盖率低的文件
console.log('\n=== 覆盖率低的文件 ===');
Object.entries(coverageSummary).forEach(([filePath, coverage]) => {
if (filePath !== 'total' && coverage.lines.pct < 80) {
console.log(`${filePath}: ${coverage.lines.pct}% 行覆盖率`);
}
});
return passed;
} catch (error) {
console.error('生成覆盖率报告失败:', error.message);
return false;
}
}
// 集成到package.json
// package.json
{
"scripts": {
"test": "jest",
"test:coverage": "node scripts/monitor-coverage.js",
"test:watch": "jest --watch",
"test:generate": "node scripts/generate-tests.js",
"test:quality": "bash ~/.claude/skills/anti-test-theater/scripts/check-test-quality.sh src/"
}
}
8. 测试文化的建设与推广
8.1 建立团队的测试思维
工具和技术只是基础,更重要的是建立团队的测试文化。以下是一些实践建议:
定期举办测试工作坊 :
- 每月一次,分享测试最佳实践
- 演示如何使用
anti-test-theater改进测试 - 代码审查中重点关注测试质量
- 分享测试失败的教训和解决方案
建立测试知识库 :
# 测试知识库
## 常见问题与解决方案
### 问题:测试运行太慢
**解决方案**:
1. 使用jest的`--maxWorkers`选项并行运行测试
2. 将慢测试标记为`@slow`,在CI中单独运行
3. 使用内存数据库代替真实数据库
4. Mock外部API调用
### 问题:测试不稳定(Flaky Tests)
**解决方案**:
1. 避免固定时间的等待,使用`waitFor`或`findBy*`
2. 使用可重试的测试工具
3. 隔离有副作用的测试
4. 使用测试容器确保环境一致性
### 问题:测试维护成本高
**解决方案**:
1. 使用工厂函数和构建器模式创建测试数据
2. 将通用测试逻辑提取到工具函数中
3. 使用参数化测试减少重复
4. 定期重构测试代码
## 测试模式库
### 数据库测试模式
```typescript
// 使用事务回滚
test('数据库操作', async () => {
await db.transaction(async (trx) => {
// 在事务中执行操作
await trx.insert(users).values({ name: 'Test' });
// 测试断言
const result = await trx.select().from(users);
expect(result).toHaveLength(1);
// 事务会自动回滚,不污染数据库
});
});
// 使用测试夹具
beforeAll(async () => {
// 设置测试数据
await db.insert(users).values([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
});
afterAll(async () => {
// 清理测试数据
await db.delete(users);
});
API测试模式
// 测试请求验证
test('验证请求参数', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid' }); // 缺少必要字段
expect(response.status).toBe(400);
expect(response.body.errors).toContain('email');
});
// 测试认证和授权
test('需要认证的端点', async () => {
// 未认证的请求
const unauthResponse = await request(app).get('/api/profile');
expect(unauthResponse.status).toBe(401);
// 已认证的请求
const authResponse = await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer valid-token');
expect(authResponse.status).toBe(200);
});
测试工具推荐
单元测试
- Jest : JavaScript/TypeScript测试框架
- Vitest : 更快的Vite原生测试框架
- Mocha + Chai : 灵活的测试组合
集成测试
- Supertest : API端点测试
- MSW :
更多推荐



所有评论(0)