31b - AI测试生成工作流
本文是《AI Agent 实战手册》第 31 章第 2 节。 上一节:31a-AI辅助测试概览 | 下一节:31c-需求到测试用例自动化
概述
AI 测试生成已从”在 Chat 中请求写测试”演进为端到端的自动化工作流——从源代码分析、测试策略选择、框架适配到测试执行与反馈循环,AI Agent 能在数分钟内为整个模块生成覆盖单元、集成和 E2E 三个层级的测试套件。本节深入拆解每种测试类型的 AI 生成工作流,提供 Jest、Vitest、Pytest、Go testing 四大框架的特定模式和提示词模板,帮助你建立可复用的 AI 测试生成管线。
1. AI 测试生成工作流总览
1.1 三层测试生成架构
AI 测试生成并非”一个 prompt 搞定一切”,而是一个分层、迭代的工作流。不同测试类型对 AI 的上下文需求、生成策略和审查重点各不相同。
┌─────────────────────────────────────────────────────────────────────┐
│ AI 测试生成三层架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: 单元测试生成 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 输入:函数签名 + 实现代码 + 类型定义 │ │
│ │ 策略:逐函数分析 → 路径枚举 → 边界推导 → 断言生成 │ │
│ │ 输出:*.test.ts / *_test.py / *_test.go │ │
│ │ 审查重点:断言正确性、边界覆盖、mock 合理性 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Layer 2: 集成测试生成 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 输入:API 规范 + 服务依赖图 + 数据模型 + 业务流程 │ │
│ │ 策略:端点枚举 → 依赖分析 → 环境策略 → 场景组合 │ │
│ │ 输出:*.integration.test.ts / test_*.py / *_integration_test.go│ │
│ │ 审查重点:环境隔离、数据清理、异步处理、业务语义 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Layer 3: E2E 测试生成 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 输入:用户流程图 + 页面结构 + 认证方式 + 测试环境配置 │ │
│ │ 策略:流程分解 → 页面对象建模 → 交互脚本 → 断言 + 截图 │ │
│ │ 输出:*.e2e.ts / *.spec.ts (Playwright/Cypress) │ │
│ │ 审查重点:选择器稳定性、等待策略、数据隔离、可维护性 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘1.2 工作流核心循环
无论哪种测试类型,AI 测试生成都遵循一个核心循环:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ① 上下文 │────→│ ② 策略 │────→│ ③ 生成 │────→│ ④ 执行 │
│ 收集 │ │ 选择 │ │ 测试 │ │ 验证 │
└──────────┘ └──────────┘ └──────────┘ └────┬─────┘
│
┌──────────┐ ┌──────────┐ │
│ ⑥ 人工 │←────│ ⑤ 修复 │←──────────────────────────┘
│ 审查 │ │ 迭代 │ (失败时自动修复,最多 3 轮)
└──────────┘ └──────────┘各步骤详解:
| 步骤 | 动作 | AI 角色 | 人工角色 |
|---|---|---|---|
| ① 上下文收集 | 分析源代码、类型、依赖、已有测试 | 自动分析项目结构 | 提供业务上下文和测试约定 |
| ② 策略选择 | 确定测试类型、框架、覆盖策略 | 根据代码特征推荐策略 | 确认或调整策略 |
| ③ 生成测试 | 编写测试代码 | 生成完整测试文件 | — |
| ④ 执行验证 | 运行测试、检查通过率 | 自动运行并分析结果 | — |
| ⑤ 修复迭代 | 修复失败的测试 | 分析失败原因并修复 | 判断是测试错误还是代码 bug |
| ⑥ 人工审查 | 审查测试质量 | — | 验证断言正确性和覆盖完整性 |
1.3 工具推荐
| 工具 | 用途 | 价格 | 适用场景 |
|---|---|---|---|
| Claude Code | Agent 模式全项目测试生成 + 自动修复循环 | $20/月(Pro)/ $100/月(Max 5x) | 深度测试生成、复杂业务逻辑 |
| Kiro | Spec 驱动测试生成 + Hooks 自动触发 | 免费(50 次/月)/ $19/月(Pro) | 结构化测试工作流 |
| GitHub Copilot | 内联补全 + Chat 生成 + 专用测试工作流 | 免费(2000 补全/月)/ $10/月(Pro) | 日常快速测试生成 |
| Cursor | Tab 补全 + Agent 模式测试生成 | 免费(2000 补全/月)/ $20/月(Pro) | IDE 内快速测试迭代 |
| Qodo (原 CodiumAI) | 上下文感知测试建议 + 行为覆盖分析 | 免费(个人)/ $19/月(Teams) | 开发者日常单元测试 |
| Vitest | 下一代 JavaScript/TypeScript 测试框架 | 免费(开源) | Vite 项目、ESM 项目 |
| Jest | 成熟的 JavaScript 测试框架 | 免费(开源) | React 项目、企业级项目 |
| Pytest | Python 测试框架 | 免费(开源) | Python 项目 |
| Go testing | Go 标准库测试包 | 免费(内置) | Go 项目 |
| fast-check | JavaScript/TypeScript PBT 框架 | 免费(开源) | 前端/Node.js 属性测试 |
| Hypothesis | Python PBT 框架 | 免费(开源) | Python 属性测试 |
| Playwright | 跨浏览器 E2E 测试框架 | 免费(开源) | Web 应用 E2E 测试 |
2. 单元测试 AI 生成工作流
单元测试是 AI 生成质量最高的测试类型。函数签名和类型信息为 AI 提供了充分的上下文,使其能够系统化地枚举测试路径。
2.1 单元测试生成的五步工作流
步骤 1 步骤 2 步骤 3 步骤 4 步骤 5
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 函数分析 │──→│ 路径枚举 │──→│ 测试生成 │──→│ 运行验证 │──→│ 覆盖率 │
│ │ │ │ │ │ │ │ │ 补充 │
│ • 签名 │ │ • 正常路径│ │ • 框架适配│ │ • 全部通过│ │ • 变异测试│
│ • 类型 │ │ • 边界值 │ │ • AAA 模式│ │ • 失败修复│ │ • 盲区补充│
│ • 依赖 │ │ • 异常路径│ │ • 命名规范│ │ • 最多3轮 │ │ • 人工审查│
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘2.2 框架特定模式:Jest
Jest 是 React 生态的标准测试框架,AI 对 Jest 的模式识别最为成熟。
Jest 测试生成提示词模板
你是一个资深 JavaScript/TypeScript 测试工程师。请为以下代码生成 Jest 单元测试。
## 源代码
```typescript
[粘贴源代码]项目测试约定
- 测试框架:Jest + @testing-library/react(如涉及组件)
- 文件命名:
*.test.ts,与源文件同目录 - 测试风格:describe/it 嵌套,AAA 模式
- 命名格式:
should [预期行为] when [条件] - TypeScript 严格模式
生成要求
- 覆盖以下场景:
- 正常路径(至少 2 个典型输入)
- 边界值(空字符串、空数组、0、负数、最大安全整数)
- 异常路径(null、undefined、类型错误、超出范围)
- 异步行为(如涉及 Promise/async)
- 断言要具体:
- ❌ 避免
toBeTruthy()、toBeDefined() - ✅ 使用
toBe()、toEqual()、toMatchObject()、toThrow()
- ❌ 避免
- Mock 策略:
- 仅 mock 外部依赖(HTTP 请求、数据库、文件系统)
- 不 mock 被测模块的内部函数
- 使用
jest.spyOn()而非jest.fn()替换已有实现
- 每个 describe 块包含 beforeEach/afterEach 清理逻辑(如需要)
#### Jest 生成示例
**源代码:**
```typescript
// src/utils/price-calculator.ts
export interface CartItem {
name: string;
price: number;
quantity: number;
category: 'food' | 'electronics' | 'clothing';
}
export interface DiscountRule {
type: 'percentage' | 'fixed';
value: number;
minAmount: number;
categories?: string[];
}
export function calculateTotal(
items: CartItem[],
discount?: DiscountRule
): { subtotal: number; discountAmount: number; total: number } {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
let discountAmount = 0;
if (discount && subtotal >= discount.minAmount) {
const eligibleAmount = discount.categories
? items
.filter(item => discount.categories!.includes(item.category))
.reduce((sum, item) => sum + item.price * item.quantity, 0)
: subtotal;
discountAmount = discount.type === 'percentage'
? eligibleAmount * (discount.value / 100)
: Math.min(discount.value, eligibleAmount);
}
return {
subtotal: Math.round(subtotal * 100) / 100,
discountAmount: Math.round(discountAmount * 100) / 100,
total: Math.round((subtotal - discountAmount) * 100) / 100,
};
}AI 生成的 Jest 测试:
// src/utils/price-calculator.test.ts
import { calculateTotal, CartItem, DiscountRule } from './price-calculator';
describe('calculateTotal', () => {
// ── 测试数据工厂 ──
const createItem = (
overrides: Partial<CartItem> = {}
): CartItem => ({
name: 'Test Item',
price: 10,
quantity: 1,
category: 'food',
...overrides,
});
// ── 正常路径 ──
describe('正常计算', () => {
it('should calculate subtotal for single item', () => {
const items = [createItem({ price: 29.99, quantity: 2 })];
const result = calculateTotal(items);
expect(result.subtotal).toBe(59.98);
expect(result.discountAmount).toBe(0);
expect(result.total).toBe(59.98);
});
it('should calculate subtotal for multiple items', () => {
const items = [
createItem({ price: 10, quantity: 3 }),
createItem({ price: 25.5, quantity: 1, category: 'electronics' }),
];
const result = calculateTotal(items);
expect(result.subtotal).toBe(55.5);
expect(result.total).toBe(55.5);
});
});
// ── 折扣逻辑 ──
describe('百分比折扣', () => {
it('should apply percentage discount when subtotal meets minimum', () => {
const items = [createItem({ price: 100, quantity: 1 })];
const discount: DiscountRule = {
type: 'percentage',
value: 20,
minAmount: 50,
};
const result = calculateTotal(items, discount);
expect(result.discountAmount).toBe(20);
expect(result.total).toBe(80);
});
it('should not apply discount when subtotal below minimum', () => {
const items = [createItem({ price: 10, quantity: 1 })];
const discount: DiscountRule = {
type: 'percentage',
value: 20,
minAmount: 50,
};
const result = calculateTotal(items, discount);
expect(result.discountAmount).toBe(0);
expect(result.total).toBe(10);
});
it('should apply category-specific percentage discount', () => {
const items = [
createItem({ price: 50, quantity: 1, category: 'food' }),
createItem({ price: 50, quantity: 1, category: 'electronics' }),
];
const discount: DiscountRule = {
type: 'percentage',
value: 10,
minAmount: 50,
categories: ['food'],
};
const result = calculateTotal(items, discount);
expect(result.subtotal).toBe(100);
expect(result.discountAmount).toBe(5); // 10% of food items (50)
expect(result.total).toBe(95);
});
});
describe('固定金额折扣', () => {
it('should apply fixed discount', () => {
const items = [createItem({ price: 100, quantity: 1 })];
const discount: DiscountRule = {
type: 'fixed',
value: 15,
minAmount: 50,
};
const result = calculateTotal(items, discount);
expect(result.discountAmount).toBe(15);
expect(result.total).toBe(85);
});
it('should cap fixed discount at eligible amount', () => {
const items = [createItem({ price: 20, quantity: 1, category: 'food' })];
const discount: DiscountRule = {
type: 'fixed',
value: 50,
minAmount: 10,
categories: ['food'],
};
const result = calculateTotal(items, discount);
expect(result.discountAmount).toBe(20); // capped at eligible amount
expect(result.total).toBe(0);
});
});
// ── 边界值 ──
describe('边界条件', () => {
it('should return zeros for empty cart', () => {
const result = calculateTotal([]);
expect(result.subtotal).toBe(0);
expect(result.discountAmount).toBe(0);
expect(result.total).toBe(0);
});
it('should handle zero-price items', () => {
const items = [createItem({ price: 0, quantity: 5 })];
const result = calculateTotal(items);
expect(result.total).toBe(0);
});
it('should handle floating point precision', () => {
const items = [createItem({ price: 0.1, quantity: 3 })];
const result = calculateTotal(items);
// 0.1 * 3 = 0.30000000000000004 without rounding
expect(result.subtotal).toBe(0.3);
});
});
// ── 无折扣 ──
describe('无折扣场景', () => {
it('should work without discount parameter', () => {
const items = [createItem({ price: 42, quantity: 1 })];
const result = calculateTotal(items);
expect(result.discountAmount).toBe(0);
expect(result.total).toBe(42);
});
it('should work with undefined discount', () => {
const items = [createItem({ price: 42, quantity: 1 })];
const result = calculateTotal(items, undefined);
expect(result.total).toBe(42);
});
});
});Jest 关键模式总结
| 模式 | 说明 | 示例 |
|---|---|---|
| 测试数据工厂 | 用 createItem() 工厂函数避免重复 | const createItem = (overrides) => ({...defaults, ...overrides}) |
| describe 嵌套 | 按功能分组,层次清晰 | describe('百分比折扣', () => { ... }) |
| AAA 模式 | Arrange-Act-Assert 三段式 | 准备数据 → 调用函数 → 验证结果 |
| 精确断言 | 避免模糊断言 | toBe(20) 而非 toBeTruthy() |
| 边界值覆盖 | 空数组、零值、浮点精度 | calculateTotal([]) |
2.3 框架特定模式:Vitest
Vitest 是 2025-2026 年 JavaScript/TypeScript 项目的首选测试框架,尤其适合 Vite 生态和 ESM 项目。其 API 与 Jest 高度兼容,但在性能和 DX 上有显著优势。
Vitest vs Jest:AI 生成时的关键差异
| 特性 | Jest | Vitest | AI 生成影响 |
|---|---|---|---|
| 模块系统 | CommonJS 为主 | ESM 原生支持 | Vitest 不需要 jest.mock() 的 hoisting hack |
| 配置 | jest.config.js | vitest.config.ts(复用 Vite 配置) | Vitest 配置更简洁 |
| Mock | jest.fn() / jest.spyOn() | vi.fn() / vi.spyOn() | API 前缀不同 |
| Timer Mock | jest.useFakeTimers() | vi.useFakeTimers() | 同上 |
| 快照 | toMatchSnapshot() | toMatchSnapshot()(兼容) | 完全相同 |
| 并行执行 | 文件级并行 | 文件级 + 测试级并行 | Vitest 更快 |
| 类型检查 | 需要额外配置 | 内置 typecheck 模式 | Vitest 可同时验证类型 |
| 覆盖率 | --coverage(需安装 istanbul) | @vitest/coverage-v8(V8 原生) | Vitest 覆盖率更快 |
Vitest 测试生成提示词模板
你是一个资深 TypeScript 测试工程师。请为以下代码生成 Vitest 单元测试。
## 源代码
```typescript
[粘贴源代码]项目测试约定
- 测试框架:Vitest(使用 vi 而非 jest)
- 文件命名:
*.test.ts,与源文件同目录 - 导入方式:
import { describe, it, expect, vi, beforeEach } from 'vitest' - 测试风格:describe/it 嵌套,AAA 模式
- 命名格式:
should [预期行为] when [条件]
Vitest 特定要求
- 使用
vi.fn()而非jest.fn() - 使用
vi.spyOn()而非jest.spyOn() - 使用
vi.useFakeTimers()而非jest.useFakeTimers() - Mock 模块使用
vi.mock()而非jest.mock() - 利用 Vitest 的
vi.stubEnv()处理环境变量 - 使用
expect.soft()进行非中断断言(适合多断言场景) - 异步测试使用
vi.waitFor()而非手动 setTimeout
覆盖要求
- 正常路径(至少 2 个典型输入)
- 边界值(空输入、极值)
- 异常路径(无效输入、错误处理)
- 如涉及异步:成功/失败/超时三种场景
#### Vitest 生成示例
**源代码:**
```typescript
// src/services/user-service.ts
import { db } from '../db';
import { hash } from '../utils/crypto';
export interface CreateUserInput {
email: string;
password: string;
name: string;
}
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
export async function createUser(input: CreateUserInput): Promise<User> {
const existing = await db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new Error(`User with email ${input.email} already exists`);
}
if (input.password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const hashedPassword = await hash(input.password);
return db.user.create({
data: {
email: input.email.toLowerCase().trim(),
name: input.name.trim(),
password: hashedPassword,
},
});
}AI 生成的 Vitest 测试:
// src/services/user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser, CreateUserInput } from './user-service';
// Mock 外部依赖
vi.mock('../db', () => ({
db: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock('../utils/crypto', () => ({
hash: vi.fn().mockResolvedValue('hashed_password_123'),
}));
import { db } from '../db';
import { hash } from '../utils/crypto';
describe('createUser', () => {
const validInput: CreateUserInput = {
email: 'alice@example.com',
password: 'securePass123',
name: 'Alice Smith',
};
beforeEach(() => {
vi.clearAllMocks();
// 默认:用户不存在
vi.mocked(db.user.findUnique).mockResolvedValue(null);
// 默认:创建成功
vi.mocked(db.user.create).mockResolvedValue({
id: 'user_001',
email: 'alice@example.com',
name: 'Alice Smith',
createdAt: new Date('2025-01-01'),
});
});
// ── 正常路径 ──
describe('成功创建用户', () => {
it('should create user with valid input', async () => {
const result = await createUser(validInput);
expect(result).toMatchObject({
id: 'user_001',
email: 'alice@example.com',
name: 'Alice Smith',
});
});
it('should normalize email to lowercase and trim', async () => {
await createUser({
...validInput,
email: ' Alice@Example.COM ',
});
expect(db.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: 'alice@example.com',
}),
});
});
it('should trim name whitespace', async () => {
await createUser({
...validInput,
name: ' Alice Smith ',
});
expect(db.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: 'Alice Smith',
}),
});
});
it('should hash password before storing', async () => {
await createUser(validInput);
expect(hash).toHaveBeenCalledWith('securePass123');
expect(db.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
password: 'hashed_password_123',
}),
});
});
});
// ── 异常路径 ──
describe('错误处理', () => {
it('should throw when email already exists', async () => {
vi.mocked(db.user.findUnique).mockResolvedValue({
id: 'existing',
email: 'alice@example.com',
name: 'Existing User',
createdAt: new Date(),
});
await expect(createUser(validInput)).rejects.toThrow(
'User with email alice@example.com already exists'
);
expect(db.user.create).not.toHaveBeenCalled();
});
it('should throw when password is too short', async () => {
await expect(
createUser({ ...validInput, password: 'short' })
).rejects.toThrow('Password must be at least 8 characters');
expect(db.user.create).not.toHaveBeenCalled();
});
});
// ── 边界值 ──
describe('边界条件', () => {
it('should accept password with exactly 8 characters', async () => {
await expect(
createUser({ ...validInput, password: '12345678' })
).resolves.toBeDefined();
});
it('should reject password with 7 characters', async () => {
await expect(
createUser({ ...validInput, password: '1234567' })
).rejects.toThrow('Password must be at least 8 characters');
});
});
});Vitest 独有特性在测试生成中的应用
// Vitest 独有:expect.soft() — 非中断断言
it('should return complete user object', async () => {
const result = await createUser(validInput);
// 所有断言都会执行,即使前面的失败了
expect.soft(result.id).toBeDefined();
expect.soft(result.email).toBe('alice@example.com');
expect.soft(result.name).toBe('Alice Smith');
expect.soft(result.createdAt).toBeInstanceOf(Date);
});
// Vitest 独有:vi.stubEnv() — 环境变量 mock
it('should use production hash rounds in production', async () => {
vi.stubEnv('NODE_ENV', 'production');
await createUser(validInput);
expect(hash).toHaveBeenCalledWith('securePass123');
vi.unstubAllEnvs();
});
// Vitest 独有:内联快照
it('should format error message correctly', async () => {
vi.mocked(db.user.findUnique).mockResolvedValue({
id: 'existing', email: 'test@test.com', name: 'Test', createdAt: new Date(),
});
await expect(
createUser({ ...validInput, email: 'test@test.com' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"User with email test@test.com already exists"`
);
});2.4 框架特定模式:Pytest
Pytest 是 Python 生态中最流行的测试框架,其 fixture 系统和参数化能力使 AI 生成的测试更加简洁和可维护。
Pytest 核心特性与 AI 生成策略
| Pytest 特性 | AI 生成策略 | 优势 |
|---|---|---|
| Fixture | AI 自动识别共享依赖,生成 @pytest.fixture | 减少重复代码,提高可维护性 |
| 参数化 | AI 用 @pytest.mark.parametrize 合并相似测试 | 一个测试函数覆盖多个输入 |
| 异常断言 | AI 用 pytest.raises() 上下文管理器 | 比 try/except 更简洁 |
| 临时目录 | AI 用 tmp_path fixture 处理文件操作 | 自动清理,测试隔离 |
| Monkeypatch | AI 用 monkeypatch 替代手动 mock | 更 Pythonic 的 mock 方式 |
| Conftest | AI 将共享 fixture 提取到 conftest.py | 跨文件复用 |
Pytest 测试生成提示词模板
你是一个资深 Python 测试工程师。请为以下代码生成 Pytest 单元测试。
## 源代码
```python
[粘贴源代码]项目测试约定
- 测试框架:Pytest(不使用 unittest 风格)
- 文件命名:
test_*.py,放在tests/目录下 - 测试函数命名:
test_[功能]_[场景]_[预期结果] - 使用 Pytest fixture 管理测试数据和依赖
- 使用
@pytest.mark.parametrize合并相似测试
Pytest 特定要求
- 使用 Pytest 原生断言(
assert),不使用self.assertEqual() - 使用
@pytest.fixture管理测试数据,而非在每个测试中重复创建 - 使用
@pytest.mark.parametrize参数化边界值测试 - 使用
pytest.raises()测试异常 - 使用
monkeypatch或unittest.mock.patch处理外部依赖 - 异步测试使用
@pytest.mark.asyncio(需要 pytest-asyncio) - 使用
tmp_pathfixture 处理文件操作测试 - 使用
capsysfixture 捕获标准输出
覆盖要求
- 正常路径(至少 2 个典型输入)
- 边界值(空字符串、空列表、0、None)
- 异常路径(无效输入、类型错误)
- 参数化测试覆盖多种输入组合
#### Pytest 生成示例
**源代码:**
```python
# src/services/inventory.py
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class StockStatus(Enum):
IN_STOCK = "in_stock"
LOW_STOCK = "low_stock"
OUT_OF_STOCK = "out_of_stock"
@dataclass
class Product:
sku: str
name: str
quantity: int
reorder_threshold: int = 10
class InventoryService:
def __init__(self, products: Optional[list[Product]] = None):
self._products: dict[str, Product] = {}
if products:
for p in products:
self._products[p.sku] = p
def add_stock(self, sku: str, amount: int) -> Product:
if amount <= 0:
raise ValueError("Amount must be positive")
if sku not in self._products:
raise KeyError(f"Product {sku} not found")
product = self._products[sku]
product.quantity += amount
return product
def remove_stock(self, sku: str, amount: int) -> Product:
if amount <= 0:
raise ValueError("Amount must be positive")
if sku not in self._products:
raise KeyError(f"Product {sku} not found")
product = self._products[sku]
if product.quantity < amount:
raise ValueError(
f"Insufficient stock: have {product.quantity}, need {amount}"
)
product.quantity -= amount
return product
def get_status(self, sku: str) -> StockStatus:
if sku not in self._products:
raise KeyError(f"Product {sku} not found")
product = self._products[sku]
if product.quantity == 0:
return StockStatus.OUT_OF_STOCK
elif product.quantity <= product.reorder_threshold:
return StockStatus.LOW_STOCK
else:
return StockStatus.IN_STOCK
def get_reorder_list(self) -> list[Product]:
return [
p for p in self._products.values()
if p.quantity <= p.reorder_threshold
]AI 生成的 Pytest 测试:
# tests/test_inventory.py
import pytest
from src.services.inventory import (
InventoryService,
Product,
StockStatus,
)
# ── Fixtures ──
@pytest.fixture
def sample_products() -> list[Product]:
return [
Product(sku="SKU001", name="Widget A", quantity=50, reorder_threshold=10),
Product(sku="SKU002", name="Widget B", quantity=5, reorder_threshold=10),
Product(sku="SKU003", name="Widget C", quantity=0, reorder_threshold=5),
]
@pytest.fixture
def service(sample_products: list[Product]) -> InventoryService:
return InventoryService(products=sample_products)
@pytest.fixture
def empty_service() -> InventoryService:
return InventoryService()
# ── add_stock 测试 ──
class TestAddStock:
def test_add_stock_increases_quantity(self, service: InventoryService):
result = service.add_stock("SKU001", 10)
assert result.quantity == 60
assert result.sku == "SKU001"
def test_add_stock_to_zero_quantity_product(self, service: InventoryService):
result = service.add_stock("SKU003", 20)
assert result.quantity == 20
@pytest.mark.parametrize("invalid_amount", [0, -1, -100])
def test_add_stock_rejects_non_positive_amount(
self, service: InventoryService, invalid_amount: int
):
with pytest.raises(ValueError, match="Amount must be positive"):
service.add_stock("SKU001", invalid_amount)
def test_add_stock_unknown_sku_raises_key_error(
self, service: InventoryService
):
with pytest.raises(KeyError, match="Product UNKNOWN not found"):
service.add_stock("UNKNOWN", 10)
# ── remove_stock 测试 ──
class TestRemoveStock:
def test_remove_stock_decreases_quantity(self, service: InventoryService):
result = service.remove_stock("SKU001", 10)
assert result.quantity == 40
def test_remove_exact_quantity(self, service: InventoryService):
result = service.remove_stock("SKU002", 5)
assert result.quantity == 0
def test_remove_stock_insufficient_raises_error(
self, service: InventoryService
):
with pytest.raises(ValueError, match="Insufficient stock"):
service.remove_stock("SKU002", 10)
def test_remove_from_zero_stock_raises_error(
self, service: InventoryService
):
with pytest.raises(ValueError, match="Insufficient stock"):
service.remove_stock("SKU003", 1)
@pytest.mark.parametrize("invalid_amount", [0, -1, -50])
def test_remove_stock_rejects_non_positive_amount(
self, service: InventoryService, invalid_amount: int
):
with pytest.raises(ValueError, match="Amount must be positive"):
service.remove_stock("SKU001", invalid_amount)
# ── get_status 测试 ──
class TestGetStatus:
@pytest.mark.parametrize(
"sku, expected_status",
[
("SKU001", StockStatus.IN_STOCK), # quantity=50 > threshold=10
("SKU002", StockStatus.LOW_STOCK), # quantity=5 <= threshold=10
("SKU003", StockStatus.OUT_OF_STOCK), # quantity=0
],
)
def test_status_matches_quantity(
self,
service: InventoryService,
sku: str,
expected_status: StockStatus,
):
assert service.get_status(sku) == expected_status
def test_status_at_threshold_is_low_stock(self):
"""quantity == reorder_threshold 应该是 LOW_STOCK"""
product = Product(sku="EDGE", name="Edge", quantity=10, reorder_threshold=10)
service = InventoryService(products=[product])
assert service.get_status("EDGE") == StockStatus.LOW_STOCK
def test_status_one_above_threshold_is_in_stock(self):
"""quantity == reorder_threshold + 1 应该是 IN_STOCK"""
product = Product(sku="EDGE", name="Edge", quantity=11, reorder_threshold=10)
service = InventoryService(products=[product])
assert service.get_status("EDGE") == StockStatus.IN_STOCK
def test_status_unknown_sku_raises_key_error(
self, service: InventoryService
):
with pytest.raises(KeyError):
service.get_status("NONEXISTENT")
# ── get_reorder_list 测试 ──
class TestGetReorderList:
def test_returns_low_and_out_of_stock_products(
self, service: InventoryService
):
reorder = service.get_reorder_list()
skus = {p.sku for p in reorder}
assert skus == {"SKU002", "SKU003"}
def test_empty_service_returns_empty_list(
self, empty_service: InventoryService
):
assert empty_service.get_reorder_list() == []
def test_all_in_stock_returns_empty_list(self):
products = [
Product(sku="A", name="A", quantity=100, reorder_threshold=10),
Product(sku="B", name="B", quantity=50, reorder_threshold=5),
]
service = InventoryService(products=products)
assert service.get_reorder_list() == []
# ── 初始化测试 ──
class TestInit:
def test_init_with_no_products(self):
service = InventoryService()
assert service.get_reorder_list() == []
def test_init_with_none(self):
service = InventoryService(products=None)
assert service.get_reorder_list() == []Pytest 关键模式总结
| 模式 | 说明 | 示例 |
|---|---|---|
| Fixture 层次 | 用 fixture 管理测试数据和服务实例 | @pytest.fixture def service() |
| 参数化 | 用 parametrize 合并相似测试 | @pytest.mark.parametrize("amount", [0, -1, -100]) |
| 异常断言 | 用 pytest.raises + match 验证异常 | with pytest.raises(ValueError, match="...") |
| Class 分组 | 用 TestXxx 类按功能分组 | class TestAddStock: |
| 边界值参数化 | 将边界值测试参数化 | ("sku, expected_status", [...]) |
| Fixture 组合 | 不同 fixture 组合测试不同场景 | empty_service vs service |
2.5 框架特定模式:Go testing
Go 的测试哲学与 JavaScript/Python 截然不同——没有 describe/it 嵌套,没有断言库(标准库),测试就是普通函数。AI 生成 Go 测试时需要适应这种”极简主义”风格。
Go testing 核心特性与 AI 生成策略
| Go testing 特性 | AI 生成策略 | 与 JS/Python 的差异 |
|---|---|---|
| Table-Driven Tests | AI 将多个测试用例组织为 []struct 表格 | 替代 describe/it 嵌套 |
| Subtests | AI 用 t.Run() 创建子测试 | 类似 it() 但更扁平 |
| 无断言库 | AI 用 if got != want + t.Errorf() | 无 expect().toBe() |
| testify(可选) | AI 可使用 assert.Equal() 简化断言 | 第三方库,非标准 |
| TestMain | AI 用 TestMain() 管理全局 setup/teardown | 类似 beforeAll/afterAll |
| t.Helper() | AI 在辅助函数中调用 t.Helper() | 改善错误报告行号 |
| t.Parallel() | AI 标记可并行的测试 | 默认串行,需显式声明 |
| t.Cleanup() | AI 用 t.Cleanup() 注册清理函数 | 类似 afterEach |
Go testing 测试生成提示词模板
你是一个资深 Go 测试工程师。请为以下代码生成 Go 测试。
## 源代码
```go
[粘贴源代码]项目测试约定
- 测试框架:Go 标准库 testing(不使用 testify,除非明确要求)
- 文件命名:
*_test.go,与源文件同包 - 测试函数命名:
Test[函数名]_[场景](如TestCalculate_EmptyInput) - 使用 Table-Driven Tests 组织多个测试用例
- 使用
t.Run()创建子测试 - 辅助函数使用
t.Helper()
Go 特定要求
- 使用 Table-Driven Tests 模式:
tests := []struct { name string input InputType want OutputType wantErr bool }{ ... } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) } - 错误检查模式:
if err != nil && !tt.wantErr { t.Fatalf("unexpected error: %v", err) } if err == nil && tt.wantErr { t.Fatal("expected error, got nil") } - 使用
t.Helper()标记辅助函数 - 可并行的测试添加
t.Parallel() - 使用
t.Cleanup()注册清理逻辑 - 使用
cmp.Diff()对比复杂结构体(需要github.com/google/go-cmp)
覆盖要求
- 正常路径(至少 2 个典型输入)
- 边界值(空切片、零值、nil)
- 错误路径(无效输入、错误返回)
- Table-Driven 覆盖多种输入组合
#### Go testing 生成示例
**源代码:**
```go
// pkg/calculator/calculator.go
package calculator
import (
"errors"
"math"
)
type Operation string
const (
Add Operation = "add"
Subtract Operation = "subtract"
Multiply Operation = "multiply"
Divide Operation = "divide"
Power Operation = "power"
)
var ErrDivisionByZero = errors.New("division by zero")
var ErrUnknownOperation = errors.New("unknown operation")
var ErrOverflow = errors.New("result overflow")
func Calculate(op Operation, a, b float64) (float64, error) {
var result float64
switch op {
case Add:
result = a + b
case Subtract:
result = a - b
case Multiply:
result = a * b
case Divide:
if b == 0 {
return 0, ErrDivisionByZero
}
result = a / b
case Power:
result = math.Pow(a, b)
default:
return 0, ErrUnknownOperation
}
if math.IsInf(result, 0) || math.IsNaN(result) {
return 0, ErrOverflow
}
return result, nil
}AI 生成的 Go 测试:
// pkg/calculator/calculator_test.go
package calculator
import (
"math"
"testing"
)
func TestCalculate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
op Operation
a, b float64
want float64
wantErr error
}{
// ── 加法 ──
{name: "add positive numbers", op: Add, a: 2, b: 3, want: 5},
{name: "add negative numbers", op: Add, a: -2, b: -3, want: -5},
{name: "add zero", op: Add, a: 5, b: 0, want: 5},
{name: "add decimals", op: Add, a: 0.1, b: 0.2, want: 0.30000000000000004},
// ── 减法 ──
{name: "subtract positive", op: Subtract, a: 10, b: 3, want: 7},
{name: "subtract resulting negative", op: Subtract, a: 3, b: 10, want: -7},
{name: "subtract zero", op: Subtract, a: 5, b: 0, want: 5},
// ── 乘法 ──
{name: "multiply positive", op: Multiply, a: 4, b: 5, want: 20},
{name: "multiply by zero", op: Multiply, a: 100, b: 0, want: 0},
{name: "multiply negatives", op: Multiply, a: -3, b: -4, want: 12},
// ── 除法 ──
{name: "divide evenly", op: Divide, a: 10, b: 2, want: 5},
{name: "divide with remainder", op: Divide, a: 7, b: 3, want: 7.0 / 3.0},
{name: "divide by zero", op: Divide, a: 10, b: 0, wantErr: ErrDivisionByZero},
// ── 幂运算 ──
{name: "power positive", op: Power, a: 2, b: 10, want: 1024},
{name: "power zero exponent", op: Power, a: 5, b: 0, want: 1},
{name: "power negative exponent", op: Power, a: 2, b: -1, want: 0.5},
// ── 错误场景 ──
{name: "unknown operation", op: "modulo", a: 10, b: 3, wantErr: ErrUnknownOperation},
// ── 溢出 ──
{name: "overflow large power", op: Power, a: 10, b: 309, wantErr: ErrOverflow},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := Calculate(tt.op, tt.a, tt.b)
if tt.wantErr != nil {
if err == nil {
t.Fatalf("expected error %v, got nil", tt.wantErr)
}
if err != tt.wantErr {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Calculate(%q, %v, %v) = %v, want %v",
tt.op, tt.a, tt.b, got, tt.want)
}
})
}
}
// ── 边界值专项测试 ──
func TestCalculate_BoundaryValues(t *testing.T) {
t.Parallel()
t.Run("max float64 addition", func(t *testing.T) {
t.Parallel()
_, err := Calculate(Add, math.MaxFloat64, math.MaxFloat64)
if err != ErrOverflow {
t.Errorf("expected overflow error, got %v", err)
}
})
t.Run("very small numbers", func(t *testing.T) {
t.Parallel()
got, err := Calculate(Add, math.SmallestNonzeroFloat64, math.SmallestNonzeroFloat64)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got <= 0 {
t.Errorf("expected positive result, got %v", got)
}
})
t.Run("negative zero division", func(t *testing.T) {
t.Parallel()
got, err := Calculate(Divide, -0.0, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 0 {
t.Errorf("expected 0, got %v", got)
}
})
}
// ── 辅助函数示例 ──
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertError(t *testing.T, got, want error) {
t.Helper()
if got == nil {
t.Fatalf("expected error %v, got nil", want)
}
if got != want {
t.Fatalf("expected error %v, got %v", want, got)
}
}
func TestCalculate_WithHelpers(t *testing.T) {
t.Parallel()
t.Run("add with helper", func(t *testing.T) {
t.Parallel()
got, err := Calculate(Add, 1, 2)
assertNoError(t, err)
if got != 3 {
t.Errorf("got %v, want 3", got)
}
})
t.Run("divide by zero with helper", func(t *testing.T) {
t.Parallel()
_, err := Calculate(Divide, 1, 0)
assertError(t, err, ErrDivisionByZero)
})
}Go testing 关键模式总结
| 模式 | 说明 | 示例 |
|---|---|---|
| Table-Driven Tests | 用结构体切片组织测试用例 | tests := []struct{ name string; ... }{ ... } |
| Subtests | 用 t.Run() 创建命名子测试 | t.Run("add positive", func(t *testing.T) { ... }) |
| Parallel | 用 t.Parallel() 标记可并行测试 | 外层和内层都需要调用 |
| Helper 函数 | 用 t.Helper() 改善错误报告 | func assertNoError(t *testing.T, err error) |
| Error Sentinel | 用预定义错误变量对比 | if err != ErrDivisionByZero |
| 无断言库 | 用 if/t.Errorf 替代断言 | if got != want { t.Errorf(...) } |
3. 集成测试 AI 生成工作流
集成测试验证组件间的交互,AI 在这个层级需要更多的上下文引导——不仅需要源代码,还需要 API 规范、数据模型、依赖关系和环境配置。
3.1 集成测试生成的六步工作流
步骤 1 步骤 2 步骤 3
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 依赖分析 │──→│ 环境策略 │──→│ 场景设计 │
│ │ │ │ │ │
│ • API 规范│ │ • 真实 DB │ │ • 正常流程│
│ • 数据模型│ │ • 内存 DB │ │ • 错误处理│
│ • 服务依赖│ │ • Container│ │ • 并发场景│
│ • 认证方式│ │ • Mock │ │ • 幂等性 │
└──────────┘ └──────────┘ └──────────┘
│ │
▼ ▼
步骤 6 步骤 5 步骤 4
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 数据清理 │←──│ 执行验证 │←──│ 测试生成 │
│ │ │ │ │ │
│ • 事务回滚│ │ • 运行测试│ │ • 框架适配│
│ • 数据删除│ │ • 检查日志│ │ • Setup │
│ • 状态重置│ │ • 修复失败│ │ • Teardown│
└──────────┘ └──────────┘ └──────────┘3.2 环境策略选择决策树
需要测试数据库交互?
├── 是 ──→ 数据量大或需要真实行为?
│ ├── 是 ──→ 使用 Testcontainers(Docker 容器)
│ └── 否 ──→ 使用内存数据库(SQLite / H2)
└── 否 ──→ 需要测试外部 API?
├── 是 ──→ 使用 HTTP Mock(MSW / WireMock / httptest)
└── 否 ──→ 使用函数级 Mock3.3 框架特定集成测试模式
Vitest/Jest + Supertest(Node.js API 集成测试)
请为以下 Express/Fastify API 端点生成集成测试。
## API 规范
- 端点:[HTTP 方法] [路径]
- 功能:[功能描述]
- 请求体:[JSON Schema 或示例]
- 响应体:[JSON Schema 或示例]
- 认证:[Bearer Token / API Key / 无]
- 依赖:[数据库 / 外部 API / 消息队列]
## 环境策略
- 数据库:[Testcontainers PostgreSQL / SQLite 内存 / Mock]
- 外部 API:[MSW mock / 真实沙箱环境]
- 认证:[测试 token / mock 认证中间件]
## 测试场景要求
1. 正常请求返回正确响应(200/201)
2. 缺少必填字段返回 400
3. 未认证请求返回 401
4. 无权限请求返回 403
5. 资源不存在返回 404
6. 重复创建返回 409(如适用)
7. 并发请求的幂等性(如适用)
## 框架要求
- 使用 [Vitest/Jest] + supertest
- 每个测试独立,不依赖执行顺序
- 包含 beforeAll(启动服务器)和 afterAll(关闭服务器)
- 每个测试前后清理测试数据Vitest + Supertest 生成示例:
// tests/integration/users.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { db } from '../../src/db';
describe('POST /api/users', () => {
let server: ReturnType<typeof app.listen>;
beforeAll(async () => {
await db.migrate.latest();
server = app.listen(0); // 随机端口
});
afterAll(async () => {
await server.close();
await db.destroy();
});
beforeEach(async () => {
await db('users').truncate();
});
// ── 正常路径 ──
it('should create user and return 201', async () => {
const response = await request(server)
.post('/api/users')
.send({
email: 'alice@example.com',
name: 'Alice',
password: 'securePass123',
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
email: 'alice@example.com',
name: 'Alice',
});
expect(response.body).not.toHaveProperty('password');
// 验证数据库中确实创建了用户
const dbUser = await db('users')
.where({ email: 'alice@example.com' })
.first();
expect(dbUser).toBeDefined();
expect(dbUser.name).toBe('Alice');
});
// ── 验证错误 ──
it('should return 400 when email is missing', async () => {
const response = await request(server)
.post('/api/users')
.send({ name: 'Alice', password: 'securePass123' })
.expect(400);
expect(response.body.error).toContain('email');
});
it('should return 400 when password is too short', async () => {
const response = await request(server)
.post('/api/users')
.send({
email: 'alice@example.com',
name: 'Alice',
password: 'short',
})
.expect(400);
expect(response.body.error).toContain('password');
});
// ── 冲突 ──
it('should return 409 when email already exists', async () => {
// 先创建用户
await request(server)
.post('/api/users')
.send({
email: 'alice@example.com',
name: 'Alice',
password: 'securePass123',
})
.expect(201);
// 再次创建同邮箱用户
const response = await request(server)
.post('/api/users')
.send({
email: 'alice@example.com',
name: 'Alice 2',
password: 'anotherPass123',
})
.expect(409);
expect(response.body.error).toContain('already exists');
});
// ── 幂等性验证 ──
it('should handle concurrent creation attempts gracefully', async () => {
const userData = {
email: 'concurrent@example.com',
name: 'Concurrent User',
password: 'securePass123',
};
const results = await Promise.allSettled([
request(server).post('/api/users').send(userData),
request(server).post('/api/users').send(userData),
]);
const statuses = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled')
.map(r => r.value.status);
// 一个成功,一个冲突
expect(statuses.sort()).toEqual([201, 409]);
// 数据库中只有一条记录
const count = await db('users')
.where({ email: 'concurrent@example.com' })
.count('* as count')
.first();
expect(Number(count?.count)).toBe(1);
});
});Pytest + httpx(Python API 集成测试)
# tests/integration/test_users_api.py
import pytest
import httpx
from testcontainers.postgres import PostgresContainer
from src.app import create_app
from src.db import init_db
@pytest.fixture(scope="module")
def postgres():
"""启动 PostgreSQL 容器"""
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="module")
def app(postgres):
"""创建应用实例"""
app = create_app(database_url=postgres.get_connection_url())
init_db(app)
return app
@pytest.fixture
def client(app):
"""创建测试客户端"""
with httpx.Client(app=app, base_url="http://test") as client:
yield client
@pytest.fixture(autouse=True)
def clean_db(app):
"""每个测试前清理数据"""
yield
with app.db.begin() as conn:
conn.execute("TRUNCATE users CASCADE")
class TestCreateUser:
def test_create_user_returns_201(self, client: httpx.Client):
response = client.post("/api/users", json={
"email": "alice@example.com",
"name": "Alice",
"password": "securePass123",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "alice@example.com"
assert "password" not in data
def test_missing_email_returns_400(self, client: httpx.Client):
response = client.post("/api/users", json={
"name": "Alice",
"password": "securePass123",
})
assert response.status_code == 400
assert "email" in response.json()["error"]
def test_duplicate_email_returns_409(self, client: httpx.Client):
user_data = {
"email": "alice@example.com",
"name": "Alice",
"password": "securePass123",
}
client.post("/api/users", json=user_data)
response = client.post("/api/users", json=user_data)
assert response.status_code == 409Go + httptest(Go API 集成测试)
// internal/api/users_test.go
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"myapp/internal/api"
"myapp/internal/db"
)
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
testDB := db.NewInMemory()
t.Cleanup(func() { testDB.Close() })
handler := api.NewRouter(testDB)
return httptest.NewServer(handler)
}
func TestCreateUser(t *testing.T) {
t.Parallel()
server := setupTestServer(t)
defer server.Close()
tests := []struct {
name string
body map[string]string
wantStatus int
wantError string
}{
{
name: "valid user returns 201",
body: map[string]string{
"email": "alice@example.com",
"name": "Alice",
"password": "securePass123",
},
wantStatus: http.StatusCreated,
},
{
name: "missing email returns 400",
body: map[string]string{
"name": "Alice",
"password": "securePass123",
},
wantStatus: http.StatusBadRequest,
wantError: "email",
},
{
name: "short password returns 400",
body: map[string]string{
"email": "alice@example.com",
"name": "Alice",
"password": "short",
},
wantStatus: http.StatusBadRequest,
wantError: "password",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
resp, err := http.Post(
server.URL+"/api/users",
"application/json",
bytes.NewReader(body),
)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if tt.wantError != "" {
var result map[string]string
json.NewDecoder(resp.Body).Decode(&result)
if errMsg, ok := result["error"]; !ok || errMsg == "" {
t.Error("expected error message in response")
}
}
})
}
}
func TestCreateUser_DuplicateEmail(t *testing.T) {
t.Parallel()
server := setupTestServer(t)
defer server.Close()
userData := map[string]string{
"email": "alice@example.com",
"name": "Alice",
"password": "securePass123",
}
body, _ := json.Marshal(userData)
// 第一次创建
resp1, _ := http.Post(server.URL+"/api/users", "application/json", bytes.NewReader(body))
resp1.Body.Close()
if resp1.StatusCode != http.StatusCreated {
t.Fatalf("first create: status = %d, want 201", resp1.StatusCode)
}
// 第二次创建(重复)
body2, _ := json.Marshal(userData)
resp2, _ := http.Post(server.URL+"/api/users", "application/json", bytes.NewReader(body2))
resp2.Body.Close()
if resp2.StatusCode != http.StatusConflict {
t.Errorf("duplicate create: status = %d, want 409", resp2.StatusCode)
}
}3.4 集成测试环境工具对比
| 工具 | 语言 | 用途 | 价格 | 适用场景 |
|---|---|---|---|---|
| Testcontainers | Java/Go/Node/Python/.NET | Docker 容器化测试依赖 | 免费(开源)/ Cloud 付费 | 需要真实数据库/消息队列的集成测试 |
| MSW (Mock Service Worker) | JavaScript/TypeScript | 拦截 HTTP 请求并返回 mock 响应 | 免费(开源) | 前端集成测试、API mock |
| WireMock | Java(HTTP API 通用) | HTTP API mock 服务器 | 免费(开源)/ Cloud 付费 | 后端集成测试、契约测试 |
| httptest | Go | Go 标准库 HTTP 测试服务器 | 免费(内置) | Go API 集成测试 |
| supertest | JavaScript/TypeScript | HTTP 断言库 | 免费(开源) | Node.js API 集成测试 |
| httpx | Python | 异步 HTTP 客户端(支持 ASGI 测试) | 免费(开源) | Python API 集成测试 |
| SQLite 内存模式 | 通用 | 内存数据库替代 | 免费(内置) | 轻量级数据库集成测试 |
| Pact | 多语言 | 消费者驱动的契约测试 | 免费(开源)/ Pactflow 付费 | 微服务间 API 契约验证 |
4. E2E 测试 AI 生成工作流
E2E 测试验证完整的用户流程,是维护成本最高但业务价值最大的测试类型。AI 在 E2E 测试生成中的核心价值在于:自动化 Page Object 建模、智能选择器生成和自愈能力。
4.1 E2E 测试生成的七步工作流
步骤 1 步骤 2 步骤 3 步骤 4
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 流程定义 │──→│ 页面建模 │──→│ 选择器 │──→│ 脚本生成 │
│ │ │ │ │ 策略 │ │ │
│ • 用户故事│ │ • Page │ │ • data- │ │ • 交互 │
│ • 关键路径│ │ Object │ │ testid │ │ • 断言 │
│ • 前置条件│ │ • 组件 │ │ • role │ │ • 等待 │
│ • 测试数据│ │ 抽象 │ │ • text │ │ • 截图 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
步骤 7 步骤 6 步骤 5 │
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ CI 集成 │←──│ 自愈维护 │←──│ 执行调试 │←───────┘
│ │ │ │ │ │
│ • 并行 │ │ • 选择器 │ │ • 本地运行│
│ • 重试 │ │ 更新 │ │ • 截图对比│
│ • 报告 │ │ • 流程 │ │ • 日志分析│
│ • 通知 │ │ 适配 │ │ • 修复 │
└──────────┘ └──────────┘ └──────────┘4.2 Playwright E2E 测试生成
Playwright 是 2025-2026 年 E2E 测试的首选框架,其自动等待、多浏览器支持和 Codegen 工具使 AI 生成的测试更加稳定。
Playwright E2E 测试生成提示词模板
你是一个资深 E2E 测试工程师。请为以下用户流程生成 Playwright E2E 测试。
## 用户流程
流程名称:[名称]
前置条件:[用户状态、数据准备]
步骤:
1. [步骤描述]
2. [步骤描述]
3. [步骤描述]
...
预期结果:[最终验证点]
## 技术要求
- 框架:Playwright(TypeScript)
- 使用 Page Object 模式
- 选择器优先级:data-testid > role > text > CSS
- 使用 Playwright 自动等待(不使用硬编码 sleep)
- 每个关键步骤添加断言
- 失败时自动截图
- 使用 `test.describe` 组织相关测试
- 使用 `test.beforeEach` 处理登录等前置条件
## 选择器策略
1. 优先使用 `data-testid` 属性:`page.getByTestId('submit-btn')`
2. 其次使用语义角色:`page.getByRole('button', { name: 'Submit' })`
3. 再次使用文本:`page.getByText('Welcome')`
4. 最后使用 CSS(仅在以上都不适用时):`page.locator('.submit-btn')`
## 数据策略
- 测试数据通过 API 创建(不通过 UI)
- 每个测试独立,不依赖其他测试的数据
- 测试后清理创建的数据Playwright 生成示例:电商结账流程
// tests/e2e/checkout.e2e.ts
import { test, expect, Page } from '@playwright/test';
// ── Page Objects ──
class ProductPage {
constructor(private page: Page) {}
async goto(productId: string) {
await this.page.goto(`/products/${productId}`);
}
async addToCart() {
await this.page.getByRole('button', { name: '加入购物车' }).click();
await expect(
this.page.getByText('已添加到购物车')
).toBeVisible();
}
async selectQuantity(qty: number) {
await this.page.getByTestId('quantity-selector').fill(String(qty));
}
}
class CartPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/cart');
}
async getItemCount() {
return this.page.getByTestId('cart-item').count();
}
async getTotal() {
const text = await this.page.getByTestId('cart-total').textContent();
return parseFloat(text?.replace(/[^0-9.]/g, '') ?? '0');
}
async proceedToCheckout() {
await this.page.getByRole('button', { name: '去结算' }).click();
await expect(this.page).toHaveURL(/\/checkout/);
}
}
class CheckoutPage {
constructor(private page: Page) {}
async fillShippingAddress(address: {
name: string;
phone: string;
address: string;
city: string;
}) {
await this.page.getByLabel('收件人').fill(address.name);
await this.page.getByLabel('手机号').fill(address.phone);
await this.page.getByLabel('详细地址').fill(address.address);
await this.page.getByLabel('城市').fill(address.city);
}
async selectPaymentMethod(method: 'credit_card' | 'alipay' | 'wechat') {
await this.page.getByTestId(`payment-${method}`).click();
}
async placeOrder() {
await this.page.getByRole('button', { name: '提交订单' }).click();
}
async getOrderConfirmation() {
await expect(
this.page.getByText('订单提交成功')
).toBeVisible({ timeout: 10000 });
const orderId = await this.page.getByTestId('order-id').textContent();
return orderId;
}
}
// ── 测试套件 ──
test.describe('电商结账流程', () => {
let productPage: ProductPage;
let cartPage: CartPage;
let checkoutPage: CheckoutPage;
test.beforeEach(async ({ page }) => {
productPage = new ProductPage(page);
cartPage = new CartPage(page);
checkoutPage = new CheckoutPage(page);
// 通过 API 登录(比 UI 登录更快更稳定)
const response = await page.request.post('/api/auth/login', {
data: { email: 'test@example.com', password: 'testPass123' },
});
const { token } = await response.json();
await page.context().addCookies([{
name: 'auth_token',
value: token,
domain: 'localhost',
path: '/',
}]);
});
test('完整结账流程:添加商品 → 购物车 → 结算 → 订单确认', async ({ page }) => {
// Step 1: 浏览商品并添加到购物车
await productPage.goto('prod-001');
await productPage.selectQuantity(2);
await productPage.addToCart();
// Step 2: 查看购物车
await cartPage.goto();
const itemCount = await cartPage.getItemCount();
expect(itemCount).toBe(1);
const total = await cartPage.getTotal();
expect(total).toBeGreaterThan(0);
// Step 3: 进入结算
await cartPage.proceedToCheckout();
// Step 4: 填写收货地址
await checkoutPage.fillShippingAddress({
name: '张三',
phone: '13800138000',
address: '中关村大街1号',
city: '北京',
});
// Step 5: 选择支付方式
await checkoutPage.selectPaymentMethod('alipay');
// Step 6: 提交订单
await checkoutPage.placeOrder();
// Step 7: 验证订单确认
const orderId = await checkoutPage.getOrderConfirmation();
expect(orderId).toBeTruthy();
expect(orderId).toMatch(/^ORD-/);
// 截图存档
await page.screenshot({
path: 'test-results/checkout-success.png',
fullPage: true,
});
});
test('购物车为空时不能结算', async ({ page }) => {
await cartPage.goto();
const checkoutBtn = page.getByRole('button', { name: '去结算' });
await expect(checkoutBtn).toBeDisabled();
await expect(page.getByText('购物车是空的')).toBeVisible();
});
test('未登录用户结算时跳转到登录页', async ({ page, context }) => {
// 清除认证
await context.clearCookies();
await cartPage.goto();
await page.getByRole('button', { name: '去结算' }).click();
await expect(page).toHaveURL(/\/login\?redirect=/);
});
});4.3 E2E 选择器策略对比
| 选择器类型 | 稳定性 | 可读性 | 维护成本 | 推荐度 | 示例 |
|---|---|---|---|---|---|
| data-testid | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 低 | 首选 | getByTestId('submit-btn') |
| ARIA role | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 低 | 推荐 | getByRole('button', { name: 'Submit' }) |
| 文本内容 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 中 | 可用 | getByText('Welcome') |
| Label | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 低 | 推荐 | getByLabel('Email') |
| CSS 选择器 | ⭐⭐ | ⭐⭐ | 高 | 避免 | locator('.btn-primary') |
| XPath | ⭐ | ⭐ | 很高 | 禁止 | locator('//div[@class="btn"]') |
4.4 E2E 测试的 AI 自愈机制
当 UI 变更导致 E2E 测试失败时,AI 可以自动修复:
E2E 测试失败
│
▼
┌──────────────────────────────────────────────┐
│ AI 分析失败原因 │
├──────────────────────────────────────────────┤
│ │
│ 选择器失败?──→ 分析 DOM 变化,更新选择器 │
│ │ │
│ 超时失败?──→ 分析加载时间,调整等待策略 │
│ │ │
│ 断言失败?──→ 分析预期值变化,判断是否为 │
│ │ 有意变更还是 bug │
│ │ │
│ 网络错误?──→ 添加重试逻辑或 mock 不稳定 API │
│ │
└──────────────────────────────────────────────┘AI 自愈提示词模板
以下 Playwright E2E 测试失败了,请分析原因并修复。
## 失败信息[粘贴测试失败的错误信息和堆栈]
## 当前测试代码
```typescript
[粘贴失败的测试代码]当前页面 HTML(如有)
[粘贴相关的 DOM 片段]分析要求
- 判断失败类型:选择器变更 / 超时 / 断言不匹配 / 网络错误
- 如果是选择器变更:
- 找到新的稳定选择器(优先 data-testid > role > text)
- 更新测试代码
- 如果是超时:
- 分析是否需要增加等待时间
- 是否需要添加 waitForResponse / waitForLoadState
- 如果是断言不匹配:
- 判断是有意的 UI 变更还是 bug
- 如果是有意变更,更新断言
- 如果是 bug,报告而非修复测试
- 提供修复后的完整测试代码
---
## 5. Test-First Prompting:先写测试再生成代码
Test-First Prompting 是 AI 时代 TDD 的进化形态——先让 AI 生成测试(或人工编写测试),再让 AI 根据测试生成实现代码。这种模式显著提高了 AI 生成代码的正确性和安全性。
### 5.1 为什么 Test-First Prompting 有效?
传统 AI 代码生成的问题:
传统模式: Prompt → AI 生成代码 → 人工写测试 → 发现 bug → 修复 问题:AI 没有约束,可能生成”看起来对但边界有 bug”的代码
Test-First 模式: Prompt → AI 生成测试 → 人工审查测试 → AI 根据测试生成代码 → 测试自动验证 优势:测试作为 AI 的约束条件,显著减少幻觉代码
| 维度 | 传统模式 | Test-First 模式 | 提升 |
|------|---------|----------------|------|
| **代码正确性** | 依赖 AI 的"猜测" | 测试约束 AI 的输出 | 显著提高 |
| **边界覆盖** | AI 可能遗漏边界 | 测试明确定义边界 | 2-3x |
| **安全性** | AI 可能忽略安全检查 | 安全测试强制 AI 实现防护 | 显著提高 |
| **可维护性** | 代码和测试可能不一致 | 代码天然与测试对齐 | 提高 |
| **审查效率** | 需要审查代码 + 测试 | 先审查测试(更简单),代码自动验证 | 2x |
### 5.2 Test-First Prompting 工作流
┌──────────────────────────────────────────────────────────────────┐ │ Test-First Prompting 工作流 │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Phase 1: 需求 → 测试 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 需求描述 │──→│ AI 生成 │──→│ 人工审查 │ │ │ │ (自然语言) │ │ 测试代码 │ │ 测试正确性│ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ Phase 2: 测试 → 代码 │ │ │ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ AI 生成 │──→│ 运行测试 │──→│ 全部通过?│ │ │ │ 实现代码 │ │ 自动验证 │ │ 是→完成 │ │ │ └──────────┘ └──────────┘ │ 否→修复 │ │ │ └──────────┘ │ │ │ │ Phase 3: 迭代优化 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 覆盖率 │──→│ 补充测试 │──→│ AI 补充 │ │ │ │ 分析 │ │ (边界/PBT)│ │ 实现代码 │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘
### 5.3 Test-First 提示词模板
#### Phase 1:从需求生成测试
我需要实现以下功能,请先生成测试代码(不要生成实现代码)。
功能需求
[描述功能需求]
验收标准
- [标准 1]
- [标准 2]
- [标准 3]
测试要求
- 框架:[Vitest/Jest/Pytest/Go testing]
- 为每个验收标准生成至少一个测试
- 包含边界值测试
- 包含错误处理测试
- 测试应该是可运行的(即使实现不存在,测试应该能编译但失败)
请只生成测试代码,我会在审查测试后让你生成实现。
#### Phase 2:从测试生成实现
以下是已审查通过的测试代码,请生成能让所有测试通过的实现代码。
测试代码
[粘贴测试代码]实现要求
- 让所有测试通过
- 不修改测试代码
- 遵循 [项目的编码规范]
- 处理测试中未覆盖的边界情况
- 添加必要的类型定义和错误处理
### 5.4 Test-First 实战示例
**需求:** 实现一个 URL 短链接服务的核心逻辑
**Phase 1:AI 生成测试**
```typescript
// src/services/url-shortener.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { UrlShortener } from './url-shortener';
describe('UrlShortener', () => {
let shortener: UrlShortener;
beforeEach(() => {
shortener = new UrlShortener();
});
describe('shorten', () => {
it('should return a short code for valid URL', () => {
const result = shortener.shorten('https://example.com/very/long/path');
expect(result.shortCode).toHaveLength(8);
expect(result.shortCode).toMatch(/^[a-zA-Z0-9]+$/);
expect(result.originalUrl).toBe('https://example.com/very/long/path');
});
it('should return same short code for same URL', () => {
const result1 = shortener.shorten('https://example.com');
const result2 = shortener.shorten('https://example.com');
expect(result1.shortCode).toBe(result2.shortCode);
});
it('should return different codes for different URLs', () => {
const result1 = shortener.shorten('https://example.com/a');
const result2 = shortener.shorten('https://example.com/b');
expect(result1.shortCode).not.toBe(result2.shortCode);
});
it('should throw for invalid URL', () => {
expect(() => shortener.shorten('not-a-url')).toThrow('Invalid URL');
});
it('should throw for empty string', () => {
expect(() => shortener.shorten('')).toThrow('URL is required');
});
});
describe('resolve', () => {
it('should resolve short code to original URL', () => {
const { shortCode } = shortener.shorten('https://example.com');
const originalUrl = shortener.resolve(shortCode);
expect(originalUrl).toBe('https://example.com');
});
it('should return null for unknown short code', () => {
const result = shortener.resolve('unknown1');
expect(result).toBeNull();
});
it('should track access count', () => {
const { shortCode } = shortener.shorten('https://example.com');
shortener.resolve(shortCode);
shortener.resolve(shortCode);
shortener.resolve(shortCode);
const stats = shortener.getStats(shortCode);
expect(stats?.accessCount).toBe(3);
});
});
describe('getStats', () => {
it('should return stats for existing short code', () => {
const { shortCode } = shortener.shorten('https://example.com');
const stats = shortener.getStats(shortCode);
expect(stats).toMatchObject({
originalUrl: 'https://example.com',
shortCode,
accessCount: 0,
createdAt: expect.any(Date),
});
});
it('should return null for unknown short code', () => {
expect(shortener.getStats('unknown1')).toBeNull();
});
});
});Phase 2:人工审查测试后,AI 生成实现
// src/services/url-shortener.ts
import { createHash } from 'crypto';
export interface ShortenResult {
shortCode: string;
originalUrl: string;
}
export interface UrlStats {
originalUrl: string;
shortCode: string;
accessCount: number;
createdAt: Date;
}
export class UrlShortener {
private urlToCode = new Map<string, string>();
private codeToEntry = new Map<string, {
originalUrl: string;
accessCount: number;
createdAt: Date;
}>();
shorten(url: string): ShortenResult {
if (!url) {
throw new Error('URL is required');
}
try {
new URL(url);
} catch {
throw new Error('Invalid URL');
}
// 幂等:相同 URL 返回相同 code
const existing = this.urlToCode.get(url);
if (existing) {
return { shortCode: existing, originalUrl: url };
}
const shortCode = this.generateCode(url);
this.urlToCode.set(url, shortCode);
this.codeToEntry.set(shortCode, {
originalUrl: url,
accessCount: 0,
createdAt: new Date(),
});
return { shortCode, originalUrl: url };
}
resolve(shortCode: string): string | null {
const entry = this.codeToEntry.get(shortCode);
if (!entry) return null;
entry.accessCount++;
return entry.originalUrl;
}
getStats(shortCode: string): UrlStats | null {
const entry = this.codeToEntry.get(shortCode);
if (!entry) return null;
return {
originalUrl: entry.originalUrl,
shortCode,
accessCount: entry.accessCount,
createdAt: entry.createdAt,
};
}
private generateCode(url: string): string {
return createHash('sha256')
.update(url)
.digest('base64url')
.slice(0, 8);
}
}5.5 Test-First 与 Spec 驱动的结合
在 Kiro 的 Spec 驱动工作流中,Test-First Prompting 自然融入:
Requirements (验收标准)
│
▼
Design (正确性属性) ←── 这就是"测试规格"
│
▼
Tasks (测试任务 + 实现任务)
│
├── 先执行测试任务 ←── Test-First
│ │
│ ▼
└── 再执行实现任务 ←── 测试约束实现6. AI 测试生成的高级模式
6.1 批量测试生成
当需要为整个模块或目录生成测试时,使用批量生成模式:
请为以下目录中的所有公开函数生成单元测试:
目录结构:src/utils/ ├── string-utils.ts (5 个导出函数) ├── date-utils.ts (4 个导出函数) ├── array-utils.ts (6 个导出函数) └── validation.ts (8 个导出函数)
要求:
1. 每个文件生成对应的 *.test.ts 文件
2. 每个函数至少 3 个测试(正常、边界、异常)
3. 使用 Vitest 框架
4. 共享的测试工具函数放在 src/utils/__tests__/helpers.ts
5. 按优先级排序:先生成 validation.ts 的测试(业务关键)批量生成的执行策略
┌──────────────────────────────────────────────────────────┐
│ 批量测试生成策略 │
├──────────────────────────────────────────────────────────┤
│ │
│ 策略 1:逐文件生成(推荐) │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ file1 │→ │ file2 │→ │ file3 │→ │ file4 │ │
│ │ 生成+ │ │ 生成+ │ │ 生成+ │ │ 生成+ │ │
│ │ 验证 │ │ 验证 │ │ 验证 │ │ 验证 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ 优点:每步可验证,失败不影响后续 │
│ │
│ 策略 2:全量生成后验证(快速但风险高) │
│ ┌────────────────────────────────┐ ┌────────────┐ │
│ │ 一次性生成所有测试文件 │→ │ 批量运行 │ │
│ └────────────────────────────────┘ │ 修复失败 │ │
│ 优点:速度快 └────────────┘ │
│ 缺点:失败时难以定位问题 │
│ │
│ 推荐:使用策略 1,按业务优先级排序文件 │
└──────────────────────────────────────────────────────────┘6.2 增量测试生成
当代码变更时,只为变更部分生成新测试:
以下是最近的代码变更(git diff),请为变更部分生成或更新测试。
## Git Diff
```diff
[粘贴 git diff 输出]现有测试
[粘贴现有测试文件]要求
- 只为新增/修改的函数生成测试
- 不修改现有通过的测试
- 如果修改了函数签名,更新对应的测试
- 如果新增了错误处理路径,添加对应的异常测试
- 保持与现有测试的风格一致
### 6.3 从 API 文档生成测试
请根据以下 OpenAPI/Swagger 规范生成 API 集成测试。
OpenAPI 规范
[粘贴 OpenAPI YAML/JSON]生成要求
- 为每个端点生成:
- 正常请求测试(200/201)
- 参数验证测试(400)
- 认证测试(401/403)
- 资源不存在测试(404)
- 使用 [supertest/httpx/httptest] 框架
- 请求体和响应体基于 schema 生成
- 包含 header 验证(Content-Type、CORS 等)
### 6.4 从数据库 Schema 生成测试
请根据以下数据库 Schema 生成数据访问层的测试。
Schema
[粘贴 CREATE TABLE 语句或 Prisma/Drizzle schema]生成要求
- 为每个表的 CRUD 操作生成测试
- 测试约束条件(NOT NULL、UNIQUE、FOREIGN KEY)
- 测试级联删除行为
- 测试索引是否被正确使用(EXPLAIN 分析)
- 使用 [Testcontainers/SQLite 内存] 作为测试数据库
---
## 7. CI/CD 中的 AI 测试生成集成
### 7.1 CI/CD 集成架构
将 AI 测试生成集成到 CI/CD 管线中,实现"代码变更 → 自动生成测试 → 自动运行 → 自动报告"的闭环。
┌─────────────────────────────────────────────────────────────────┐ │ CI/CD 中的 AI 测试生成管线 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 开发者 Push 代码 │ │ │ │ │ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 代码分析 │──→│ 测试生成 │──→│ 测试执行 │──→│ 报告生成 │ │ │ │ │ │ │ │ │ │ │ │ │ │ • diff │ │ • 单元 │ │ • 并行 │ │ • 覆盖率 │ │ │ │ • 影响 │ │ • 集成 │ │ • 重试 │ │ • 变异 │ │ │ │ 分析 │ │ • E2E │ │ • 超时 │ │ • PR 评论│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 质量门(Quality Gate) │ │ │ │ • 覆盖率 ≥ 80% │ │ │ │ • 变异杀死率 ≥ 70% │ │ │ │ • 无新增 flaky 测试 │ │ │ │ • 所有新代码有对应测试 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘
### 7.2 GitHub Actions 集成示例
```yaml
# .github/workflows/ai-test-generation.yml
name: AI Test Generation & Validation
on:
pull_request:
branches: [main]
jobs:
test-generation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来计算 diff
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
# 运行现有测试
- name: Run existing tests
run: npx vitest run --coverage --reporter=json --outputFile=coverage.json
# 检查覆盖率门
- name: Check coverage gate
run: |
COVERAGE=$(node -e "
const report = require('./coverage.json');
console.log(report.total.lines.pct);
")
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "::warning::Coverage ${COVERAGE}% is below 80% threshold"
fi
# 运行变异测试(仅对变更文件)
- name: Mutation testing on changed files
run: |
CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- '*.ts' | grep -v '.test.ts' | tr '\n' ',')
if [ -n "$CHANGED_FILES" ]; then
npx stryker run --mutate "${CHANGED_FILES}"
fi
# 上传测试报告
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
coverage/
reports/mutation/7.3 Kiro Hooks 自动触发测试
# .kiro/hooks/auto-test-on-save.yaml
# 文件保存时自动运行相关测试
trigger: onFileSave
pattern: "src/**/*.ts"
action: askAgent
prompt: |
文件 {filePath} 已修改。请:
1. 找到对应的测试文件
2. 运行相关测试
3. 如果测试失败,分析原因
4. 如果没有对应测试,建议是否需要生成# .kiro/hooks/test-on-pr.yaml
# PR 创建时自动检查测试覆盖
trigger: onPRCreate
action: askAgent
prompt: |
新 PR 已创建。请:
1. 分析变更的文件
2. 检查每个变更文件是否有对应测试
3. 运行所有相关测试
4. 生成测试覆盖报告
5. 如果有未覆盖的新代码,建议需要的测试实战案例:全栈 SaaS 应用的 AI 测试生成
案例背景
一个全栈 SaaS 应用(Next.js + Prisma + PostgreSQL),需要在 2 天内为核心模块建立完整的测试套件。
技术栈
| 层级 | 技术 | 测试框架 |
|---|---|---|
| 前端 | Next.js 15 + React 19 | Vitest + React Testing Library |
| API | Next.js API Routes | Vitest + supertest |
| 数据库 | Prisma + PostgreSQL | Vitest + Testcontainers |
| E2E | — | Playwright |
| PBT | — | fast-check |
执行计划
Day 1 上午:单元测试(4 小时)
├── 工具函数测试(1 小时)—— AI 批量生成
├── 业务逻辑测试(2 小时)—— AI 生成 + 人工审查
└── React 组件测试(1 小时)—— AI 生成 + 人工补充
Day 1 下午:集成测试(4 小时)
├── API 端点测试(2 小时)—— AI 从 OpenAPI 生成
├── 数据库操作测试(1.5 小时)—— AI 从 Prisma schema 生成
└── 认证流程测试(0.5 小时)—— 人工设计 + AI 生成
Day 2 上午:E2E 测试(3 小时)
├── 关键用户流程(2 小时)—— AI 生成 Page Objects + 测试
└── 错误场景(1 小时)—— AI 生成
Day 2 下午:PBT + 优化(3 小时)
├── 核心业务属性测试(1.5 小时)—— AI 辅助属性发现
├── 变异测试评估(1 小时)—— 自动运行 + AI 补充
└── CI/CD 集成(0.5 小时)—— 配置 GitHub Actions案例分析
关键决策点:
- 优先级排序:先测试业务关键路径(支付、认证),再测试工具函数
- 环境策略:数据库测试使用 Testcontainers(真实 PostgreSQL),外部 API 使用 MSW mock
- AI 角色分配:
- 工具函数:AI 全自动生成(人工快速审查)
- 业务逻辑:AI 生成骨架 + 人工补充业务断言
- E2E:AI 生成 Page Objects + 人工定义关键流程
- 质量门:覆盖率 ≥ 80%,变异杀死率 ≥ 70%,零 flaky 测试
成果:
| 指标 | 目标 | 实际 |
|---|---|---|
| 测试文件数 | — | 47 个 |
| 测试用例数 | — | 312 个 |
| 代码覆盖率 | ≥ 80% | 87% |
| 变异杀死率 | ≥ 70% | 76% |
| E2E 关键流程 | 5 个 | 5 个 |
| PBT 属性 | 8 个 | 10 个 |
| 总耗时 | 2 天 | 1.5 天 |
经验总结:
- AI 生成的单元测试质量最高,几乎不需要修改
- 集成测试需要人工补充环境配置和数据清理逻辑
- E2E 测试的 Page Object 由 AI 生成效率很高,但测试流程需要人工定义
- PBT 属性发现是 AI 最有价值的贡献——AI 能发现人工容易遗漏的属性
避坑指南
❌ 常见错误
-
一次性生成所有测试
- 问题:AI 上下文窗口有限,一次性生成大量测试会导致质量下降、重复代码增多
- 正确做法:按模块逐步生成,每次生成后运行验证,再生成下一批
-
不审查 AI 生成的断言
- 问题:AI 可能生成”幻觉断言”——看起来合理但验证了错误的行为(如猜测折扣率为 85% 而实际是 80%)
- 正确做法:每个断言都要对照业务规则验证,尤其是涉及具体数值的断言
-
过度依赖 Mock
- 问题:AI 倾向于 mock 一切外部依赖,导致测试只验证了 mock 的行为而非真实行为
- 正确做法:优先使用真实依赖(Testcontainers、内存数据库),仅在必要时 mock 外部 API
-
忽略测试的可维护性
- 问题:AI 生成的测试可能包含大量硬编码值、重复代码、缺乏抽象
- 正确做法:审查后提取测试数据工厂、共享 fixture、Page Object 等抽象
-
不运行变异测试评估质量
- 问题:高覆盖率不等于高质量——测试可能覆盖了代码行但没有验证关键行为
- 正确做法:定期运行变异测试(Stryker/mutmut),补充能杀死存活变异体的测试
-
E2E 测试使用不稳定的选择器
- 问题:AI 可能使用 CSS 类名或 XPath 作为选择器,UI 变更后测试大量失败
- 正确做法:在 prompt 中明确要求使用
data-testid或 ARIA role 作为选择器
-
测试间存在隐式依赖
- 问题:AI 生成的测试可能依赖特定的执行顺序或共享状态
- 正确做法:每个测试独立,使用
beforeEach重置状态,随机顺序运行验证
-
不区分测试类型的生成策略
- 问题:用同一个 prompt 模板生成所有类型的测试,导致单元测试过于复杂、E2E 测试过于简单
- 正确做法:为每种测试类型使用专门的 prompt 模板和生成策略
✅ 最佳实践
- 建立项目级测试约定文件:在 Steering 规则中定义测试框架、命名规范、目录结构、覆盖率要求,让 AI 每次生成都遵循一致的约定
- 采用 Test-First Prompting:先让 AI 生成测试,审查通过后再生成实现代码,显著提高代码正确性
- 分层生成,逐步验证:按单元→集成→E2E 的顺序生成,每层生成后运行验证再进入下一层
- 用变异测试评估 AI 生成的测试质量:覆盖率只是起点,变异杀死率才是真正的质量指标
- 保持人工审查在循环中:AI 生成测试骨架,人工审查业务断言和边界覆盖,形成人机协作的最佳模式
- 利用 PBT 补充 example-based 测试:AI 擅长发现属性和设计生成器,PBT 能发现 example-based 测试遗漏的边界
- 将测试生成集成到 CI/CD:通过 GitHub Actions 或 Kiro Hooks 自动触发测试生成和运行,形成持续的质量保障
- 定期进行测试健康审计:每月审查测试套件的健康度——flaky 率、覆盖盲区、过度 mock、重复测试
相关资源与延伸阅读
-
Vitest 官方文档 — 下一代 JavaScript 测试框架的完整指南,包含 API 参考、配置和最佳实践
-
Playwright 官方文档 — 跨浏览器 E2E 测试框架,包含 Codegen、Trace Viewer 和 CI 集成指南
-
fast-check 官方文档 — JavaScript/TypeScript 属性测试框架,包含 Arbitrary 设计和收缩策略
-
Hypothesis 官方文档 — Python 属性测试框架,包含策略组合、有状态测试和数据库集成
-
Stryker Mutator — JavaScript/TypeScript/C# 变异测试框架,评估测试套件的有效性
-
Testcontainers — 用 Docker 容器管理测试依赖(数据库、消息队列等),支持多语言
-
MSW (Mock Service Worker) — 拦截网络请求的 API mock 库,支持 REST 和 GraphQL
-
Testing Library — 以用户行为为中心的测试工具集,支持 React、Vue、Angular 等
-
Go testing 标准库文档 — Go 官方测试包文档,包含 Table-Driven Tests 和 Benchmark 指南
-
Pytest 官方文档 — Python 测试框架完整指南,包含 fixture、参数化和插件生态
参考来源
- Vitest: Next Generation Testing Framework (2025 年持续更新)
- Playwright Documentation (2025 年持续更新)
- Testing while vibe coding (2025 年 1 月)
- Write Unit Tests 5x Faster with Vitest and AI (2025 年 6 月)
- Test-First Prompting: Using TDD for Secure AI-Generated Code (2025 年 6 月)
- Test-Driven Development for AI Coding Success (2025 年 6 月)
- AI Code Assistants Are Revolutionizing Test-Driven Development (2025 年 7 月)
- Testing in 2026: Jest, React Testing Library, and Full-Stack Testing Strategies (2026 年 1 月)
- The Best AI Software Testing Tools to Accelerate Your QA in 2025 (2025 年)
- Is Prompt Engineering Dead? The Case for Test-Driven AI Development (2025 年 7 月)
📖 返回 总览与导航 | 上一节:31a-AI辅助测试概览 | 下一节:31c-需求到测试用例自动化