很多项目都有这样的测试数据:
单元测试:1200 个
覆盖率:85%
CI 状态:经常是绿的
但一到发布日,团队依然紧张。
明明测试很多,为什么上线前还是没有信心?
原因通常不是“测试写得不够多”,而是测试分布错了。
有些测试只是在验证实现细节,代码一重构就大面积失败;有些测试把所有外部依赖都 Mock 掉,写得很快,却无法证明系统真的能工作;还有些关键路径根本没有测试,因为它们跨越数据库、消息队列和第三方服务,写起来最麻烦。
下面聊聊测试分层里最常见的 4 个误区。
误区一:把测试数量当成质量
测试数量本身没有意义。
一个测试如果把实现步骤全部写死:
it("should call repository.save", async () => {
await createOrder(service, {
userId: "u1",
amount: 100,
});
expect(repository.save).toHaveBeenCalledWith({
userId: "u1",
amount: 100,
status: "created",
createdAt: expect.any(Date),
});
});
它能验证“调用过某个方法”,却无法回答真正的问题:
- 订单最终状态是否正确?
- 库存不足时会不会被拦截?
- 重复请求会不会创建两笔订单?
- 数据库约束是否真的生效?
- 下游失败时会不会留下脏数据?
更糟糕的是,只要内部实现改成批量写入、增加一个字段,或者换了 Repository 的调用方式,这个测试就会失败,但业务行为并没有变化。
测试应该优先验证行为,而不是复述实现。
it("creates an order with pending payment status", async () => {
const result = await createOrder({
userId: "u1",
amount: 100,
});
expect(result.status).toBe("pending_payment");
expect(await orderRepository.findById(result.id)).toMatchObject({
userId: "u1",
amount: 100,
});
});
行为测试关注输入和输出,允许实现变化;实现测试关注函数怎么调用,通常会限制重构。
一个简单判断标准是:
如果重构内部实现但不改变业务行为,测试为什么必须失败?
如果答案是“因为它检查了具体调用步骤”,这个测试可能就在错误的层级。
误区二:Mock 太多,测试变成了“验证 Mock”
单元测试经常需要隔离外部依赖,但 Mock 的边界很关键。
下面这种测试很常见:
vi.mock("./database");
vi.mock("./messageQueue");
vi.mock("./paymentClient");
vi.mock("./clock");
it("creates an order", async () => {
database.insert.mockResolvedValue({ id: "o1" });
messageQueue.publish.mockResolvedValue(undefined);
paymentClient.create.mockResolvedValue({ ok: true });
const result = await createOrder();
expect(result.id).toBe("o1");
});
这段代码确实很快,也没有真实副作用。但它只能证明:
在数据库、消息队列、支付客户端都按预期返回的情况下,
createOrder 的代码路径不会抛异常。
它没有验证:
- SQL 真的能执行;
- 数据库约束是否符合预期;
- 消息格式能否被消费者解析;
- 支付客户端超时时会不会误判成功;
- 事务隔离和并发行为是否正确。
更合理的做法是把依赖分成两类:
领域内依赖:仓储接口、时钟、ID 生成器
基础设施依赖:数据库、Redis、MQ、HTTP Client
领域内依赖可以使用内存实现或 Fake:
class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async save(order: Order) {
this.orders.set(order.id, order);
}
async findById(id: string) {
return this.orders.get(id) ?? null;
}
}
基础设施依赖则应该安排集成测试,使用真实协议或尽可能接近真实的替身:
- 数据库:Testcontainers 或临时实例;
- Redis:真实版本或兼容实现;
- 消息队列:真实 Broker 或轻量测试容器;
- HTTP:本地 Stub Server,而不是直接替换整个客户端模块。
Fake 的重点是实现可靠的最小行为,而不是记录调用次数。Mock 适合验证协议边界,不适合把整个系统拆成互不相关的零件。
误区三:只测自己,不测契约
微服务里最常见的一种故障是:
A 服务的单元测试全过;
B 服务的单元测试全过;
A、B 联调时 JSON 字段对不上。
单元测试看不到这种问题,因为双方都只在自己的世界里验证。
跨服务接口需要契约测试。
契约可以分为三层:
1. 结构契约:字段名、类型、必填项
2. 语义契约:错误码、状态流转、幂等语义
3. 行为契约:超时、重试、限流、兼容性
最简单的结构契约可以用 Schema 校验:
const orderCreatedEventSchema = z.object({
eventId: z.string().uuid(),
eventType: z.literal("order.created"),
occurredAt: z.string().datetime(),
data: z.object({
orderId: z.string(),
userId: z.string(),
amount: z.number().positive(),
}),
});
const event = orderCreatedEventSchema.parse(rawEvent);
对于服务调用,可以使用 OpenAPI、Protobuf 或消费者驱动契约:
生产者消费者共同维护契约
-> 生产者在 CI 中验证契约
-> 消费者在 CI 中使用契约生成 Stub
-> 契约变化必须显式评审
契约测试不能替代端到端测试,但它能用较低成本拦住大量接口不兼容问题。
尤其是事件驱动系统,消息一旦发出去,消费者可能很多。没有契约版本管理,生产者的一次字段重命名就可能引发链式故障。
误区四:测试不稳定,却用重跑掩盖问题
“这个测试偶尔失败,重跑一下就好了。”
如果这句话经常出现在团队里,测试最终会失去可信度。
不稳定测试通常来自:
- 依赖真实时间;
- 使用随机数据但没有固定种子;
- 依赖测试执行顺序;
- 异步任务没有可靠的等待条件;
- 测试数据互相污染;
- 断言超时过短;
- 外部服务不可用;
- 缺少资源清理。
例如,不要写:
it("creates order after 1 second", async () => {
createOrderAfterDelay();
await sleep(1100);
expect(await findOrder()).toBeDefined();
});
更稳定的方式是注入时钟和任务调度器:
const clock = new FakeClock();
const scheduler = new FakeScheduler(clock);
scheduler.scheduleAfter(1000, async () => {
await createOrder();
});
await clock.advanceBy(1000);
await scheduler.drain();
expect(await findOrder()).toBeDefined();
对于集成测试,也要显式等待状态变化,而不是猜一个 Sleep 时间:
await waitFor(async () => {
const order = await findOrder(orderId);
return order?.status === "paid";
}, {
timeout: 5000,
interval: 100,
});
不稳定测试不能简单重跑,而应该记录失败现场并修复根因。
重跑可以作为临时缓解,但如果它成为常态,团队就会开始忽略测试结果。一个经常失败但总能重跑成功的测试,和没有测试几乎没有区别。
更实用的测试组合
测试金字塔不是固定比例,而是一种风险分配方式。
对大多数后端系统,可以从下面 5 层开始:
1. 纯函数测试
覆盖金额计算、状态机、规则判断、数据转换。执行快,成本低。
2. 领域用例测试
使用 Fake Repository 和 Fake Clock,验证业务流程、边界条件和错误分支。
3. 基础设施集成测试
连接真实数据库、缓存或消息队列,验证 SQL、事务、序列化和并发行为。
4. 接口契约测试
检查 HTTP/Gateway、事件消息、外部调用和上下游兼容性。
5. 关键路径 E2E
只覆盖少量真正关键的用户流程,例如支付、登录、下单和退款。它不追求覆盖所有分支,而是验证系统真的能连起来。
优先级不是“测试越靠下越好”,而是:
- 越靠近业务核心,越应该测试行为;
- 越靠近基础设施,越应该测试真实协议;
- 越跨系统,越应该关注契约和关键路径;
- 越容易变化,越不要用测试锁死实现细节。
一套可以在 CI 中落地的策略
可以把测试分成三个层次执行。
快速反馈
lint + typecheck + unit tests
要求在几分钟内完成,适合每次提交运行。
合并前验证
component tests + integration tests + contract tests
允许耗时更长,但结果必须稳定,失败要能定位。
发布前验证
critical path E2E + migration tests + rollback checks
数量不需要很多,但必须覆盖最高风险路径。
同时记录这些指标:
- 测试运行时间;
- 不稳定测试比例;
- 失败后的平均定位时间;
- 生产缺陷能否被测试提前发现;
- 回滚与迁移是否有自动化验证。
覆盖率可以作为观察指标,但不应该成为唯一目标。一个没有被断言的代码块,即使执行过,也不代表它被测试了。
最后检查这 7 项
- 测试优先验证业务行为,而不是内部调用步骤
- Mock 边界清晰,核心基础设施有集成测试
- 跨服务接口和消息有契约验证
- 时间、随机数和外部依赖可以稳定控制
- 不稳定测试会被修复,而不是长期重跑
- 关键路径有少量高价值 E2E
- CI 能提供快速反馈,并能定位失败层级
测试真正的价值,不是让覆盖率数字变漂亮,而是让团队在修改代码和发布系统时更有把握。
当测试覆盖了正确的风险,而不是只覆盖了容易写的代码,线上的不确定性才会真正下降。
如果你也在改进测试体系,也欢迎在微信公众号搜索「长安米粒贵」继续交流。