Testing Patterns技能使用说明
2026-03-29
新闻来源:网淘吧
围观:9
电脑广告
手机广告
测试模式
编写能发现错误的测试,而非仅仅通过的测试。通过覆盖率获得信心,通过隔离性获得速度。
测试金字塔
| 层级 | 比例 | 速度 | 成本 | 可信度 | 范围 |
|---|---|---|---|---|---|
| 单元测试 | 约70% | 毫秒级 | 低 | 低(隔离性) | 单一函数/类 |
| 集成测试 | 约20% | 秒级 | 中等 | 中等 | 模块边界、API、数据库 |
| 端到端测试 | 约10% | 分钟级 | 高 | 高(真实) | 完整的用户工作流 |
规则:如果你的端到端测试数量超过了单元测试,就翻转金字塔。
单元测试模式
核心模式
| 模式 | 何时使用 | 结构 |
|---|---|---|
| 安排-执行-断言 | 所有单元测试的默认结构 | 设置、执行、验证 |
| 给定-当-那么 | BDD风格,关注行为 | 前置条件、操作、结果 |
| 参数化 | 相同逻辑,多种输入 | 数据驱动的测试用例 |
| 快照 | UI组件,序列化输出 | 与保存的基线进行比较 |
| 基于属性 | 数学不变量 | 生成随机输入,验证属性 |
准备-执行-断言 (AAA)
每个单元测试的默认结构。设置、执行和验证的清晰分离使测试具有可读性和可维护性。
// Clean AAA structure
test('calculates order total with tax', () => {
// Arrange
const items = [{ price: 10, qty: 2 }, { price: 5, qty: 1 }];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(27.0);
});
测试替身
根据情况使用正确类型的测试替身。每种类型服务于不同的目的。
| 类型 | 目的 | 何时使用 | 示例 |
|---|---|---|---|
| 桩 | 返回预设数据 | 控制间接输入 | jest.fn().mockReturnValue(42) |
| 模拟对象 | 验证交互 | 断言某方法被调用 | expect(mock).toHaveBeenCalledWith('arg') |
| 间谍 | 包装真实实现 | 观察而不替换 | jest.spyOn(service, 'save') |
| 伪造对象 | 简化实现 | 需要真实行为 | 内存数据库,模拟HTTP服务器 |
// Stub — control indirect input
const getUser = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
// Spy — observe without replacing
const spy = jest.spyOn(logger, 'warn');
processInvalidInput(data);
expect(spy).toHaveBeenCalledWith('Invalid input received');
// Fake — lightweight substitute
class FakeUserRepo implements UserRepository {
private users = new Map<string, User>();
async save(user: User) { this.users.set(user.id, user); }
async findById(id: string) { return this.users.get(id) ?? null; }
}
参数化测试
当相同逻辑需要多种输入进行验证时,使用参数化测试。这消除了复制粘贴测试,同时提供了全面的覆盖范围。
// Vitest/Jest
test.each([
['hello', 'HELLO'],
['world', 'WORLD'],
['', ''],
['123abc', '123ABC'],
])('toUpperCase(%s) returns %s', (input, expected) => {
expect(input.toUpperCase()).toBe(expected);
});
# pytest
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("", ""),
])
def test_to_upper(input, expected):
assert input.upper() == expected
// Go — table-driven tests (idiomatic)
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"zero", 0, 0, 0},
{"negative", -1, -2, -3},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Add(tc.a, tc.b); got != tc.expected {
t.Errorf("Add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.expected)
}
})
}
}
集成测试模式
数据库测试策略
| 策略 | 方法 | 权衡 |
|---|---|---|
| 事务回滚 | 将每个测试包装在事务中,结束后回滚 | 速度快,但会隐藏提交错误 |
| 固定数据/种子数据 | 在测试套件前加载已知数据 | 可预测,但若模式更改则脆弱 |
| 工厂函数 | 以编程方式生成数据 | 灵活,但需要更多设置代码 |
| Testcontainers | 在Docker中启动真实数据库 | 现实,但启动较慢 |
// Transaction rollback pattern (Prisma)
beforeEach(async () => {
await prisma.$executeRaw`BEGIN`;
});
afterEach(async () => {
await prisma.$executeRaw`ROLLBACK`;
});
test('creates user in database', async () => {
const user = await createUser({ name: 'Alice', email: 'a@b.com' });
const found = await prisma.user.findUnique({ where: { id: user.id } });
expect(found?.name).toBe('Alice');
});
API 测试
// Supertest (Node.js)
import request from 'supertest';
import { app } from '../src/app';
describe('POST /api/users', () => {
it('creates a user and returns 201', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201);
expect(res.body).toMatchObject({
id: expect.any(String),
name: 'Alice',
});
});
it('returns 400 for invalid email', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'not-an-email' })
.expect(400);
});
});
模拟最佳实践
模拟边界,而非实现
基本原则:在系统边界处(外部 API、数据库、文件系统)进行模拟,切勿模拟内部领域逻辑。
// BAD — mocking internal implementation
jest.mock('./utils/formatDate'); // Breaks on refactor
// GOOD — mocking external boundary
jest.mock('./services/paymentGateway'); // Third-party API is the boundary
何时模拟与何时不模拟
| 需要模拟 | 无需模拟 |
|---|---|
| HTTP API、外部服务 | 纯函数 |
| 数据库(在单元测试中) | 您自己的领域逻辑 |
| 文件系统、网络 | 数据转换 |
时间/日期(Date.now) | 简单计算 |
| 环境变量 | 内部类方法 |
为可测试性而进行依赖注入
组织代码结构,以便在测试中能够替换依赖项。这是实现可测试代码最具影响力的单一模式。
// Injectable dependencies — easy to test
class OrderService {
constructor(
private paymentGateway: PaymentGateway,
private inventory: InventoryService,
private notifier: NotificationService,
) {}
async placeOrder(order: Order): Promise<OrderResult> {
const stock = await this.inventory.check(order.items);
if (!stock.available) return { status: 'out_of_stock' };
const payment = await this.paymentGateway.charge(order.total);
if (!payment.success) return { status: 'payment_failed' };
await this.notifier.send(order.userId, 'Order confirmed');
return { status: 'confirmed', id: payment.transactionId };
}
}
// In tests — inject fakes
const service = new OrderService(
new FakePaymentGateway(),
new FakeInventory({ available: true }),
new FakeNotifier(),
);
框架速查表
| 框架 | 语言 | 测试类型 | 测试运行器 | 断言库 |
|---|---|---|---|---|
| Jest | JS/TS | 单元/集成测试 | 内置 | expect() |
| Vitest | JS/TS | 单元/集成测试 | Vite原生 | expect()(兼容Jest) |
| Playwright | JS/TS/Python | 端到端测试 | 内置 | expect()/ 定位器 |
| Cypress | JS/TS | 端到端 | 内置 | cy.should() |
| pytest | Python | 单元/集成 | 内置 | assert |
| Go 测试框架 | Go | 单元/集成 | go test | t.Error()/ testify |
| Rust | Rust | 单元/集成 | cargo test | assert!()/assert_eq!() |
| JUnit 5 | Java/Kotlin | 单元/集成 | 内置 | assertEquals() |
| RSpec | Ruby | 单元/集成 | 内置 | expect().to |
| PHPUnit | PHP | 单元/集成 | 内置 | $this->assert*() |
| xUnit | C# | 单元/集成 | 内置 | Assert.Equal() |
测试质量检查清单
| 质量 | 规则 | 原因 |
|---|---|---|
| 确定性 | 相同输入每次产生相同结果 | 不稳定的测试会侵蚀信任 |
| 隔离性 | 测试间无共享可变状态 | 依赖顺序的测试会在持续集成中失效 |
| 快速 | 单元测试:< 10毫秒,集成测试:< 1秒,端到端测试:< 30秒 | 缓慢的测试不会被执行 |
| 可读性强 | 测试名称描述场景和预期结果 | 测试即文档 |
| 易于维护 | 修改一个行为,只需修改一个测试 | 脆弱的测试会拖慢开发进度 |
| 专注性 | 每个测试一个逻辑断言 | 失败能准确定位问题 |
命名规范:
test_[单元]_[场景]_[预期结果]或当 [条件 Y] 时,应 [执行 X]
覆盖策略
何时以何为目标
| 目标 | 时机 | 依据 |
|---|---|---|
| 80%+ 行覆盖率 | 业务逻辑、工具函数、核心领域 | 高投资回报率 — 能捕获大多数回归问题 |
| 90%+ 分支覆盖率 | 支付处理、认证、安全关键部分 | 边界情况在此很重要 |
| 100% 覆盖率 | 几乎从不——收益递减 | Getter/setter测试增加噪音,而非信心 |
| 变异测试 | 高覆盖率后的关键路径 | 验证测试确实能捕获缺陷 |
不应测试的内容
| 跳过 | 原因 |
|---|---|
| 生成的代码(Prisma客户端,protobuf) | 由工具维护 |
| 第三方库内部实现 | 非你的职责 |
| 简单的getter/setter | 无逻辑可验证 |
| 配置文件 | 转而测试它们配置的行为 |
| Console.log / 打印语句 | 无业务价值的副作用 |
测试组织
src/
├── services/
│ ├── order.service.ts
│ └── order.service.test.ts # Co-located unit tests
├── api/
│ └── routes/
│ └── orders.ts
tests/
├── integration/
│ ├── api/
│ │ └── orders.test.ts # API integration tests
│ └── db/
│ └── order.repo.test.ts # DB integration tests
├── e2e/
│ ├── pages/ # Page objects
│ │ └── checkout.page.ts
│ └── specs/
│ └── checkout.spec.ts # E2E specs
└── helpers/
├── factories.ts # Test data factories
└── setup.ts # Global test setup
规则:单元测试应与源代码放在一起。集成测试和端到端测试应分离到专门的目录中。
反模式
| 反模式 | 问题 | 修复 |
|---|---|---|
| 测试实现细节 | 重构时测试失败,而非发现实际错误 | 测试行为和输出,而非内部实现 |
| 不稳定的测试 | 不确定性的失败会削弱对持续集成的信任 | 消除时间/顺序/网络依赖 |
| 测试污染 | 测试间共享的可变状态发生泄漏 | 在beforeEach/setUp |
| 中重置状态 | 在测试中使用休眠sleep(2000) | 速度慢且不可靠 |
| 巨型编排 | 50行配置掩盖真实意图 | 抽离工厂/构建器/夹具 |
| 无断言测试 | 测试通过但未验证任何逻辑 | 每个测试必须包含断言或期望 |
| 过度模拟 | 模拟所有对象导致测试失真 | 仅模拟外部边界 |
| 复制粘贴式测试 | 重复测试会逐渐失效 | 使用参数化测试或辅助函数 |
| 测试框架本身 | 验证库代码功能 | 测试你的业务逻辑,信任依赖项 |
| 忽视测试失败 | 跳过、xit、@Disabled标记会不断累积 | 修复或删除——绝不积压跳过的测试 |
| 与数据库紧密耦合 | 模式变更时测试失败 | 单元测试使用仓储模式+模拟对象 |
| 一个庞大的测试 | 单个测试覆盖10种场景 | 拆分为专注的、有明确命名的测试 |
| 未为缺陷修复编写测试 | 回归问题后续重现 | 每个缺陷修复都应配备回归测试 |
绝对禁止
- 绝对不要测试实现细节而非行为——测试必须验证代码的功能,而非其实现方式
- 绝对不要使用
sleep()于测试中——应使用显式等待、轮询、事件或支持自动重试的断言 - 绝对不要在测试间共享可变状态——每个测试都应自行设置和清理其状态
- 绝对不要编写无断言的测试——没有断言的测试无法证明任何问题
- 永远不要模拟内部领域逻辑——只在系统边界处(网络、数据库、文件系统、时钟)进行模拟
- 永远不要在没有关联问题和重新启用计划的情况下跳过测试——跳过的测试会逐渐腐化成永久性的空白
- 永远不要让测试套件处于失败状态——在继续之前修复它,或者给出理由后将其移除
- 永远不要以追求100%覆盖率为目标——覆盖率百分比是工具,而非目标;关键路径上的强断言胜过无处不在的弱断言
总结
| 应做 | 不应做 |
|---|---|
| 测试行为,而非实现 | 模拟所见的一切 |
| 修复错误前先编写测试 | 为了更快发布而跳过测试 |
| 保持测试快速且确定 | 使用sleep()或共享状态 |
| 使用工厂方法生成测试数据 | 跨测试复制粘贴设置 |
| 在系统边界进行模拟 | 模拟内部函数 |
| 为测试命名要有描述性 | 命名测试测试1,测试2 |
| 每次推送时在CI中运行测试 | 仅在本地运行测试 |
| 删除或修复跳过的测试 | 让@skip永久累积 |
| 对变体使用参数化测试 | 重复测试代码 |
| 为可测试性注入依赖 | 硬编码依赖 |
记住:测试是安全网——一套快速、可靠的测试套件能让你无畏重构,并充满信心地发布。
文章底部电脑广告
手机广告位-内容正文底部


微信扫一扫,打赏作者吧~