单元测试写了不少,为什么线上还是没信心?聊聊测试分层的 4 个误区

0 阅读8分钟

很多项目都有这样的测试数据:

单元测试: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 能提供快速反馈,并能定位失败层级

测试真正的价值,不是让覆盖率数字变漂亮,而是让团队在修改代码和发布系统时更有把握。

当测试覆盖了正确的风险,而不是只覆盖了容易写的代码,线上的不确定性才会真正下降。

如果你也在改进测试体系,也欢迎在微信公众号搜索「长安米粒贵」继续交流。