第 09 章(节选) 先谈可测试性
本篇来自《Go 程序员的 Java 修炼之路》第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引
09.1 先谈可测试性:测试难写,是代码的问题
在 Go 里,你大概没怎么想过”可测试性”这个词。因为 Go 的默认写法天然就是可测试的:依赖是结构体字段,测试里自己传一个实现进去,完事。
Java 不是。你在老项目里会反复撞上这三种模式,每一种都让测试写不出来。
模式一:在方法里 new 依赖
public class OrderServiceImpl implements OrderService {
public Order createOrder(CreateOrderCmd cmd) {
OrderRepository repo = new OrderRepositoryImpl(); // ① 现场 new
InventoryClient client = new InventoryClient(); // ② 还要发 HTTP
// ...
}
}repo 和 client 是这个方法的局部变量,从外面没有任何入口可以替换。你想测算价逻辑,就得真的建表、真的起库存服务。这不是单元测试,是部署演练。
在 Go 里你几乎不会写这种代码,因为你会本能地把它写成:
type OrderService struct {
repo OrderRepository // 字段,测试里可以换
client InventoryClient
}问题 2: 为什么 Go 程序员不会写 new OrderRepository(),而 Java 程序员写了二十年?
不是智商问题。Go 没有容器,依赖必须在 main() 里手动组装,这逼着你先想清楚”我需要什么”。Java 有 Spring,@Autowired 一下依赖就来了 —— 而字段注入让”不写构造器”成为可行选项,于是大量代码退化成了在方法里 new。
模式二:static 方法调用
public BigDecimal calcDiscount(Order order) {
if (LocalDateTime.now().isAfter(order.getPromotionEnd())) { // ① 时间不可控
return BigDecimal.ZERO;
}
OrderValidator.validate(order); // ② 静态工具类
String code = IdGenerator.nextId(); // ③ 随机数
// ...
}三行分别是:时间每次都不同(断言只能写”大于 0”,等于没测)、静态工具类替换不掉、随机数测不了。
Go 里 time.Now() 也是包级函数,但 Go 社区有个约定俗成的解法:
// 包级变量,测试里直接替换
var now = time.Now
func calcDiscount(o *Order) decimal.Decimal {
if now().After(o.PromotionEnd) { /* ... */ }
}
// 测试里
func TestCalcDiscount(t *testing.T) {
now = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
defer func() { now = time.Now }()
}问题 3: Java 里能这么干吗?LocalDateTime.now 是个静态方法,你没法把它赋值给一个变量。
能,但要用专门的 mechanism。Java 8 给出的官方答案叫 java.time.Clock:
public class OrderServiceImpl {
private final Clock clock;
public OrderServiceImpl(Clock clock) { // 构造器注入 Clock
this.clock = clock;
}
public BigDecimal calcDiscount(Order order) {
// now(clock) 而不是 now(),时间来源被外部化了
if (LocalDateTime.now(clock).isAfter(order.getPromotionEnd())) {
return BigDecimal.ZERO;
}
return doCalc(order);
}
}class CalcDiscountTest {
@Test
void 过了促销期折扣归零() {
// Clock.fixed:永远返回这个瞬间,测试完全确定性
Clock fixed = Clock.fixed(
Instant.parse("2026-01-01T00:00:00Z"), ZoneId.of("UTC"));
var service = new OrderServiceImpl(fixed);
// ...
}
}Clock.fixed(instant, zone) 返回一个”时间静止”的时钟。Java 里任何 now()、Math.random()、new Random()、UUID.randomUUID() 都是可测试性的敌人,都该被注入。
模式三:深层调用链
public Order createOrder(CreateOrderCmd cmd) {
validate(cmd); // 1. 校验
User user = userRepository.findById(cmd.getUserId()); // 2. 查库
BigDecimal amount = priceCalculator.calc(cmd); // 3. 算价(有优惠券逻辑)
inventoryClient.deduct(cmd.getSkuId(), cmd.getQty()); // 4. 扣库存(HTTP)
messageProducer.send(new OrderCreatedEvent(...)); // 5. 发消息
return orderRepository.save(...); // 6. 落库
}这个方法做了 6 件事。你想测第 3 步的算价(也就是你要改的地方),必须先把另外 5 步的依赖全部准备好。改一行算价逻辑要动 6 个 mock —— 最后你就不写了。
【思考】这三个问题,在 Go 里你是怎么解决的?
并排想一下:Go 靠”接口 + 依赖注入 + 小函数”这三板斧。但有一个关键差别 —— Go 的
now可以换成包级变量var now = time.Now,Java 的LocalDateTime.now()是静态方法,替换成本高得多。这个差别背后是什么?
参考答案
直接答案:Go 靠三点 —— ①依赖是结构体字段(构造时传入)②接口隐式实现(替换成本为零)③函数是一等公民(可以把 time.Now 赋值给变量)。Java 前两点都能做到,第三点做不到,所以 JDK 必须额外提供 Clock。
前两点 Java 都能做,问题在文化不在能力:Spring 的字段注入(@Autowired private OrderRepository repo;)让”不写构造器”成了默认选项,而字段注入的代码在测试里只能靠反射塞值(ReflectionTestUtils.setField),丑陋且脆弱。Go 的隐式接口让你”先用具体类型写,需要了再抽接口”;Java 必须显式 implements,所以抽接口是个需要提前做的决定。
第三点是真正的硬差别:函数是不是一等公民。
var now = time.Now // Go:函数可以当值传来传去
now = func() time.Time { return someFixedTime }Go 里 time.Now 是 func() Time 类型的值,可以赋给变量、传进函数、替换掉,测试结束时 defer 恢复即可。
Java 的 LocalDateTime.now() 是静态方法,字节码里是一条 invokestatic,调用目标编译期就定死了。你不能把它赋给变量(LocalDateTime::now 方法引用只是语法糖,底层是 Supplier 包装,原调用点的 invokestatic 不会走你的 Supplier)。
所以 JDK 8 引入 java.time 时同时提供了 Clock —— 官方在 API 设计层面为可测试性留的口子。所有需要”当前时间”的类都提供了带 Clock 的重载:
LocalDateTime.now(Clock)
Instant.now(Clock)
LocalDate.now(Clock)
YearMonth.now(Clock)代码锚点 —— 生产代码里怎么配这个 Bean:
@Configuration
public class ClockConfig {
@Bean
public Clock clock() {
return Clock.systemDefaultZone(); // 生产:真实时钟
}
}@TestConfiguration // 只在测试里生效
public class TestClockConfig {
@Bean
public Clock clock() {
return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneId.of("UTC"));
}
}更深一层:这反映了两种语言对”全局状态”的态度。 Go 允许包级变量,任何包级可变状态都是潜在全局状态,社区的应对靠”约定”(替换完要恢复、并发测试不能改)。Java 走另一条路:把”当前时间”这个隐式全局输入显式建模成对象(Clock)再注入。它更啰嗦,但把”不可控的输入”变成了类型系统里可见的东西 —— 看到构造器要 Clock,就知道这个类的行为依赖时间。
这条思路可以推广到所有不可控输入:时间用 Clock,随机数注入 RandomGenerator(Java 17),外部 HTTP 抽接口,文件系统用 Path 或 FileSystem。共同点是把隐式依赖变成显式参数。
可测试性的四个设计原则
原则一:依赖通过构造器注入,不在方法里 new
// 改前:依赖写死,测不了
public class OrderServiceImpl {
public Order createOrder(Cmd cmd) {
var repo = new OrderRepositoryImpl();
// ...
}
}
// 改后:构造器注入,测试里传什么都行
public class OrderServiceImpl {
private final OrderRepository repo;
private final Clock clock;
public OrderServiceImpl(OrderRepository repo, Clock clock) {
this.repo = repo;
this.clock = clock;
}
}注意:final 字段 + 构造器注入是 Java 里唯一推荐的注入方式。 字段注入(@Autowired 加在字段上)在测试里只能靠反射填值,而且允许对象处于”半成品状态”。这个话题第 14 章讲 Bean 生命周期时会再展开。
原则二:面向接口编程 —— 但别为了测试造接口
public interface OrderRepository { Order save(Order o); Optional<Order> findById(Long id); }问题 4: OrderRepository 只有一个实现类 MySQLOrderRepository。抽这个接口是过度设计吗?
这里要分清楚两件事,很多人把它们混为一谈了。见下面的思考。
原则三:纯函数优先(函数式核心,命令式外壳)
这个模式有个专门的名字:Functional Core, Imperative Shell(Gary Bernhardt 提出)。意思是:把所有不依赖外部世界的计算逻辑抽成纯函数,把 IO(查库、发 HTTP、发消息)留在最外层。
public class OrderServiceImpl {
// 纯函数核心:输入决定输出,没有 IO,没有时间,没有随机。测它只需要参数。
public BigDecimal calcAmount(List<OrderItem> items, Coupon coupon) {
BigDecimal sum = items.stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return coupon == null ? sum : coupon.apply(sum);
}
// 命令式外壳:只做编排,把 IO 都放在这里
public Order createOrder(CreateOrderCmd cmd) {
User user = userRepository.findById(cmd.getUserId());
Coupon coupon = couponRepository.findById(cmd.getCouponId());
BigDecimal amount = calcAmount(cmd.getItems(), coupon); // 调纯函数
return orderRepository.save(new Order(user, amount));
}
}calcAmount 的测试不需要任何 mock。这就是”函数式核心”的全部价值:把你最需要测、也最容易测错的逻辑,变成不需要 mock 的纯函数。
在 Go 里你其实早就在这么写了 —— 一个 func calcAmount(items []Item, c *Coupon) decimal.Decimal 加上一个带 IO 的 func (s *Service) CreateOrder(...)。只是 Go 没有给它起名字。
原则四:时间、随机数、外部 IO 要能被替换
| 不可控的东西 | Java 的替换方式 | Go 的替换方式 |
|---|---|---|
| 当前时间 | 注入 java.time.Clock | 包级变量 var now = time.Now |
| 随机数 | 注入 RandomGenerator(Java 17) | 注入 *rand.Rand 或包级变量 |
| 外部 HTTP | 抽接口 / WireMock / MockWebServer | 抽接口 / httptest.NewServer |
| 数据库 | 抽 Repository 接口 / Testcontainers / H2 | 抽接口 / sqlmock / sqlite |
| 消息队列 | 抽 Producer 接口 / 内存实现 | 抽接口 / 内存 channel |
| 文件系统 | FileSystem / Path 注入 | fs.FS(Go 1.16+) |
【思考】“为了可测试性而加接口”是过度设计吗?
你在 Go 社区听到的说法是”只在真有多个实现时抽接口”(accept interfaces, return structs)。但 Java 项目里到处是
XxxService+XxxServiceImpl一对一。到底谁对?
参考答案
直接答案:如果理由是”因为 mock 工具只能 mock 接口”,那这个理由在 2020 年之后就不成立了。真正需要接口的理由是”抽象”,不是”测试”。
先说历史原因,这是理解 Java 现状的钥匙。 Java 早期的 mock 框架(EasyMock、jMock、早期 Mockito)底层用的是JDK 动态代理,而它有一个硬限制:只能代理接口(生成的代理类必须实现你传入的接口数组,无法继承一个类)。
于是 2005-2015 这十年,Java 工程师面对一条铁律:想 mock 一个类,它必须实现接口。第 00 章还提过:Spring 的 @Transactional 靠动态代理,同样只能代理接口,所以为了加事务也得抽接口。两个约束叠加,“什么都抽接口”就成了政治正确 —— 满地的 XxxServiceImpl 是这么来的。
然后历史变了。 Mockito 改用 ByteBuddy 生成子类来实现 mock:
// 现代 Mockito:直接 mock 具体类,完全没问题
OrderRepository repo = mock(MySQLOrderRepository.class);代码锚点 —— 验证这一点:
class MockConcreteClassTest {
@Test
void 可以直接mock具体类() {
// 没有接口,直接 mock 一个 final 类之外的具体类
ArrayList<String> list = mock(ArrayList.class);
when(list.size()).thenReturn(42);
assertEquals(42, list.size()); // 通过
}
}(唯一保留的限制是 final 类和 final 方法 —— 子类化继承不了。09.3 讲 mockito-inline 时会解决。)
那到底什么时候该抽接口?三条:
1. 理由是”边界”,不是”数量”。 Repository、外部服务客户端、消息 Producer —— 这些是你的系统和外部世界的边界。抽接口的价值不是”可能有第二个实现”,而是”让核心逻辑不依赖具体技术”。哪怕永远只有一个 MySQL 实现,OrderRepository 接口也让 domain 层不需要 import 任何 JDBC 的类。这是依赖方向的治理,不是为测试服务。
2. 一个实现 + 纯内部类,别抽。 OrderServiceImpl implements OrderService,如果 OrderService 只被同一模块用且不可能有第二个实现 —— 这个接口就是噪音。直接写 class OrderService,需要 mock 时直接 mock 这个类。
3. Go 的 “accept interfaces” 在 Java 里的版本:Go 说”接受接口返回结构体”,因为接口是隐式的、后置定义的;Java 的接口显式且前置,所以版本是 —— 在边界处定义接口(domain 层定义 OrderRepository,infrastructure 层实现它),内部别管。
更深一层:这是”技术约束沉淀成文化,而文化活过了技术约束”的教科书案例。 JDK 动态代理的限制 2015 年前后就被 ByteBuddy 突破了,Spring 也改成了默认 CGLIB(可代理类)。但”一对一接口”已经写在几百万行代码、无数教程和代码生成模板里 —— 新人学到的就是”service 要写接口”,不问为什么。
这类东西你在 Java 生态里会不断遇到:某个最佳实践背后的技术原因早消失了,实践却留了下来。 判断方法很简单 —— 追问”如果我不这么做,具体会坏在哪里?“答不上来,那它大概率是个僵尸规范。