第 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 生态里会不断遇到:某个最佳实践背后的技术原因早消失了,实践却留了下来。 判断方法很简单 —— 追问”如果我不这么做,具体会坏在哪里?“答不上来,那它大概率是个僵尸规范。