可测的代码,是设计质量的投影

本文属于《我和苏Claw底的理想国》系列文章。该系列作品都是是作者和 OpenClaw 的对话,辩论,分析,甚至是对骂的的内容的整理。

有一类代码,你想给它写测试,却发现根本不知道从哪里下手——要 mock 的东西太多,要构造的入参太复杂,跑起来还会真的发消息、真的写数据库。这种感觉很常见,但它不是测试的问题,而是设计的问题。

测试难写,几乎总是因为代码在设计上出了问题。 测试只是把这些问题暴露出来了。

可测性是设计质量的投影

SDD(规范驱动开发)和 TDD(测试驱动开发)表面上是两种不同的开发方式,但在一件事上是同构的:都要求你在写实现之前,先把"这段代码对外承诺什么"说清楚。

TDD 用测试用例来表达这个承诺,SDD 用接口规范来表达。形式不同,逼出来的结果是一样的——你必须先想清楚边界在哪里,输入输出是什么,异常情况怎么处理。

这个过程本身就在强迫你做一件事:把实现细节和对外契约分离开。一旦这两件事分离了,可测性就自然出现了。因为测试测的就是契约——给定这个输入,我期望这个输出,我不关心你内部怎么实现的。

所以可测性不是一个独立的工程目标,它是设计质量的一个投影。一段代码越难测试,往往意味着它的设计越有问题。

测试粒度不是按访问控制划分的

一个常见的误解是:public 方法写测试,private 方法干脆不测,或者通过 public 测试尝试覆盖对应的分支。

访问控制描述的是模块边界——谁有权调用这段代码。测试粒度描述的是验证范围——这次测试覆盖了多少真实的依赖链。这是两个维度的事,不应该混为一谈。

更准确的区分方式是看依赖是否被替换。单元测试的核心特征是隔离——被测代码的所有外部依赖都被 mock 或 stub 掉了,只有被测逻辑本身在真实运行。集成测试的核心特征是穿透——至少有一个真实的外部依赖参与进来,测的是多个组件协作时的行为是否符合预期。

区分的关键问题不是"这个方法是 public 还是 private",而是"这次测试里,有没有真实的 I/O 发生"。

private 方法难以直接测试,这个"难"本身是一个信号。如果你强烈地想给一个 private 方法写测试,往往意味着这个方法承担的职责已经复杂到应该被提取成一个独立的方法或者类了。测试的阻力在给你反馈,说这里的设计可能需要调整。

怎么写出可测的代码

可测的代码可以归结为一句话:让依赖显式,让副作用隔离,让逻辑纯粹。

依赖显式化是最基本的要求。凡是方法自己去"拿"的东西,都是隐式依赖,都是测试的障碍。

// 不可测:自己去拿时间
public boolean isExpired() {
    return remoteService.getExpiredTime() > this.expireAt;
}

// 可测:时间从外面传进来
public boolean isExpired(long now) {
    return now > this.expireAt;
}

new 出来的对象、静态方法调用、ThreadLocal 里取出来的上下文——都是隐式依赖。改造方向都一样:把它变成参数,或者通过构造函数注入进来。

副作用隔离是第二件事。副作用不是不能有,而是不能和业务计算逻辑混在一起。一个典型的坏味道:

public void approveOrder(Order order) {
    order.setStatus(APPROVED);
    order.setApproveTime(System.currentTimeMillis());
    orderDao.update(order); // 落库
    messageService.sendApprovalNotification(order); // 发消息
    auditLogService.record(order); // 写流水表
}

这个方法你没法单独测"审批逻辑是否正确",因为一跑就真的写库、发消息了。改造方向是把计算和执行分开:

// 纯计算,可以直接测
public Order buildApprovedOrder(Order order, long now) {
    order.setStatus(APPROVED);
    order.setApproveTime(now);
    return order;
}

// 编排副作用,这层用集成测试覆盖
public void approveOrder(Order order) {
    Order approved = buildApprovedOrder(order, System.currentTimeMillis());
    orderDao.update(approved);
    messageService.sendApprovalNotification(approved);
    auditLogService.record(approved);
}

这三件事背后有一个共同的底层逻辑:把决策和执行分开。决策是纯粹的——给定输入,产出结果,没有副作用,可以反复运行,结果永远一致。执行是有副作用的——调外部系统,改变世界的状态。决策层天然可测,执行层用集成测试覆盖。

这和 DDD 分层是同一件事

DDD 的分层——领域层、应用层、基础设施层——背后的核心诉求就是把业务决策和技术执行隔离开。领域层是纯粹的决策,不依赖任何框架和基础设施,天然可测。应用层负责编排,基础设施层负责 I/O。

所以"可测的代码"和"好的 DDD 设计"在结构上是收敛的,不是巧合,是因为它们解决的是同一个问题:让核心逻辑不被外部依赖污染。

DDD 落地里有一个很常见的变形会破坏这个结构:领域层开始依赖 Repository。一旦这样写,领域层就有了 I/O 依赖,单元测试就必须 mock Repository,测试成本立刻上升。标准的做法是领域服务只接收已经加载好的对象,加载和保存的动作交给应用层来做:

// 应用层负责加载和保存
public class OrderAppService {
    public void approve(Long orderId) {
        Order order = orderRepository.findById(orderId);
        orderDomainService.approve(order);
        orderRepository.save(order);
    }
}

// 领域服务只做决策,没有任何 I/O
public class OrderDomainService {
    public void approve(Order order) {
        order.approve();
    }
}

这样领域层的测试就回到了纯内存计算,构造一个 Order 对象传进去,验证状态变化,不需要任何 mock。

可测性是 DDD 分层是否真正落地的一个很好的检验标准。 如果你的领域层测试需要大量 mock,说明分层在某个地方漏了。

"可测性高"是两件事

理解了决策和执行的分离,就可以回答一个更实际的问题:什么时候值得写单元测试?

先把"可测性高"拆成两层含义:

结构上可测——依赖显式、副作用隔离、职责单一,想测随时能测。

价值上值得测——有足够的决策复杂度,测试能发现真实的问题。

这两件事是独立的。一段代码可以在结构上完全可测,但因为逻辑太简单,写测试的边际收益接近零。

测试的价值和被测代码的决策复杂度正相关。一个只是 insert 一条记录的方法,逻辑是线性的,没有分支,出错的可能性极低,即使出错也一眼能看出来。这种代码写单元测试是浪费。反过来,一个方法里有五个条件判断、三种异常路径、两个状态机转换——这里的每一个分支都是潜在的 bug 藏身处,单元测试的价值在这里才真正体现出来。

所以简单 CRUD 不需要单元测试,不是因为它不可测,而是因为它没有足够的决策复杂度值得测。

判断一段代码是否值得写单元测试,圈复杂度是一个够用的触发标准。方法里每多一个 ifforswitch 分支,圈复杂度加一,超过某个阈值(比如 5),这个方法就值得有专门的单元测试覆盖。不需要一个完美的标准,一个容易执行的标准比一个精确但难落地的标准更有价值。


让代码可测,本质上是在做一件事:让错误在尽可能早、尽可能小的范围内被发现。发布后走测试发现问题,定位成本高、修复周期长。单元测试在本地跑就能发现,成本趋近于零。

但这个收益不是免费的,它要求你在写代码的时候就把决策和执行分开,把依赖显式化,把副作用隔离出去。这些不只是为了测试,它们本身就是好设计的标志。测试难写,只是在提醒你:这里的设计还不够好。