可验证设计与测试替身

共 19 题
📑 题目列表 19 题
#
★★★

1. 不可变对象(immutable object)的可测试性中天然线程安全、hash 稳定

请说明不可变对象(Immutable Object)的可测试性优势,包括天然线程安全与 hash 稳定?

  • 不可变对象的定义与线程安全原理
  • hash 稳定对集合/缓存行为的影响
  • 不可变对象降低测试复杂度与不确定性

不可变对象创建后所有字段不可修改,因此天然线程安全——无需加锁即可被多线程共享,因为不存在可变的共享状态,也就不存在数据竞争。这使得针对不可变对象的测试确定性高:不用担心并发修改、缓存失效、状态被意外改变,测试结果稳定可复现。hash 稳定是另一大优势:由于对象状态不变,hashCode() 始终保持一致,因此它作为 HashMap/HashSet 的 key 时不会因状态变化导致哈希桶错乱、查找失败;作为缓存 key 时不会因内容变化使缓存失效。可测试性上,不可变对象让测试无需考虑 setter 副作用与状态迁移,构造即完整,断言直接基于构造参数;同时天然适合作为函数式处理、并发缓存、值对象(Value Object)的建模。实践上常通过 final 字段、private 构造、工厂方法、防御性拷贝(copy-on-write)实现不可变。测试要点:验证构造后状态不可变(尝试修改应失败或产生新对象)、验证 equals/hashCode 一致性、验证作为 key 的行为稳定。

不可变对象把"可变量"从设计中消除,从而把测试中的不确定性(并发、状态迁移、副作用)一并消除。线程安全是"结构性保证"而非"测试运气"——测试不必依赖加锁正确性。hash 稳定则让对象在集合/缓存中的行为可预测,测试自然稳定。不可变对象的可测试性本质是"更少的状态、更少的意外、更确定的断言"。

public final class Money {
    private final BigDecimal amount;
    private final String currency;
    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    public Money add(Money other) {
        return new Money(amount.add(other.amount), currency); // 返回新对象
    }
    // equals/hashCode 基于 amount+currency
}

@Test
void immutableIsThreadSafeAndHashStable() {
    Money m = new Money(new BigDecimal("100"), "CNY");
    // 同一对象在多线程共享无副作用
    Money sum = m.add(new Money(new BigDecimal("50"), "CNY"));
    assertEquals(new BigDecimal("100"), m.getAmount()); // 原对象不变
    assertEquals(m.hashCode(), m.hashCode());          // hash 稳定
}
#
★★★

2. 依赖注入(DI)的可测试性中测试时替换实现

请说明依赖注入(DI)如何提升可测试性,以及测试时如何替换实现?

  • DI 的原理(构造器/接口注入)与解耦
  • 测试时替换实现(注入 fake/stub/mock)
  • DI 与可测试性的关系(可替换性)

依赖注入(Dependency Injection)把依赖的创建使用分离:类不自己 new 依赖,而是通过构造器/方法/接口由外部注入。这使类不再硬编码具体实现,从而在测试时能用测试替身(fake/stub/mock)替换真实依赖,实现隔离测试。典型做法是构造器注入class OrderService { private final PaymentGateway gateway; OrderService(PaymentGateway gateway){...} },测试时注入 new InMemoryPaymentGateway() 或 Mockito mock,而非真实网络网关。DI 提升可测试性的核心是可替换性:被测类只依赖抽象接口,换实现(真实/测试替身)不影响被测逻辑。测试时替换的实现方式:构造函数注入(最直接,测试传替身)、setter 注入(可选依赖)、接口/抽象依赖(被测类对着接口编程)。DI 还使测试能控制依赖行为(返回特定数据、抛异常)来覆盖各种分支,也能避免真实依赖(网络、数据库、外部服务)带来的慢、脆、非确定。工程实践常配合 Spring、Guice 等容器,但测试时通常手动构造对象注入替身,避免容器依赖。

DI 与可测试性是一体两面:没有 DI 的代码在 new 处硬编码依赖,测试无法替换,只能跑真实依赖(慢、脆、不可控);有了 DI,依赖以接口注入,测试可自由替换,实现"隔离 + 可控 + 快速"。构造器注入最优,因为它让依赖显式、强制、不可变,测试时一目了然需要注入什么。DI 的可测试性本质是**"面向接口、依赖外置**"。

interface PaymentGateway { boolean charge(String card, BigDecimal amount); }

class OrderService {
    private final PaymentGateway gateway;
    OrderService(PaymentGateway gateway) { this.gateway = gateway; } // 构造器注入
    boolean pay(String card, BigDecimal amt) { return gateway.charge(card, amt); }
}

@Test
void payWithStubGateway() {
    PaymentGateway stub = (card, amt) -> true;   // 测试时替换实现
    OrderService svc = new OrderService(stub);
    assertTrue(svc.pay("1234", new BigDecimal("100")));
}
#
★★★

3. 函数式编程(FP)的可测试性中组合性、引用透明性

请说明函数式编程(FP)的可测试性优势,包括组合性与引用透明性?

  • 引用透明性(Referential Transparency)与确定性
  • 组合性(Composition)与可重用测试
  • FP 减少共享状态与副作用,降低测试复杂度

函数式编程(FP)强调纯函数——输出只由输入决定,无副作用、不依赖外部可变状态。这使得函数具备引用透明性(Referential Transparency):表达式可以用其求值结果替换而不改变程序行为,因此测试确定性强——同样的输入必然得到同样的输出,无需 setup 环境、无需控制顺序、天然可并行、可缓存。组合性(Composition) 指小函数通过 map/filter/reduce/compose 等组合成更大函数,每个子函数独立可测,测试也高度可复用——针对单一函数编写测试,再用组合保证整体行为。FP 减少共享可变状态与副作用,也就减少了测试间的顺序依赖与并发隐患,降低了"测试隔离"的复杂度。测试时无需 mock 全局状态、无需关心调用顺序,直接对纯函数断言输入输出即可,失败定位也精确到具体函数。实践上(Java 用 java.util.function、Stream、Optional;JS 用纯函数、不可变数据),FP 通常配合 DI 把副作用(I/O、日志)推到边界,核心逻辑保持纯函数化,从而把"复杂逻辑"与"副作用"分离,让核心逻辑极易测试。

FP 的可测试性来自"去副作用、去共享状态":引用透明性消除了不确定性,组合性提升了测试复用。纯函数测试最简单——断言 f(input) == output 即可,无需环境、无需 mock、不受顺序影响。FP 把副作用隔离到边界,让核心逻辑纯函数化,是"可测试性设计"的典范。它把"测试依赖"压缩到最小,从结构上保证测试的确定与快速。

// 纯函数:引用透明,测试只需断言输入输出
BiFunction<BigDecimal, BigDecimal, BigDecimal> add =
    (a, b) -> a.add(b);

@Test
void pureFunctionIsDeterministic() {
    assertEquals(new BigDecimal("150"),
        add.apply(new BigDecimal("100"), new BigDecimal("50")));
    // 相同输入必得相同输出
    assertEquals(add.apply(new BigDecimal("100"), new BigDecimal("50")),
        add.apply(new BigDecimal("100"), new BigDecimal("50")));
}
#
★★★

4. 可验证设计(verifiable design)中架构层面的可测试性设计(Hexagonal、Clean、Onion)

请说明可验证设计(Verifiable Design)的含义,以及 Hexagonal、Clean、Onion 等架构如何从架构层面提升可测试性?

  • 可验证设计的含义(架构支持测试)
  • Hexagonal/Clean/Onion 的依赖方向(domain 不依赖外部)
  • 架构如何让核心逻辑充分可测

可验证设计(Verifiable Design)指在架构层面就为可测试性做准备:通过依赖方向、模块边界、端口抽象,让核心业务逻辑不依赖外部技术细节(数据库、框架、网络、UI),从而可以独立、快速、确定地测试。Hexagonal(六边形/端口-适配器)Clean(整洁架构)、**Onion(洋葱架构)**都遵循同一核心原则:依赖方向朝向内层——**领域(domain)**在最内层,不依赖任何外部;**端口(Ports/接口)**定义领域需要的输入输出;适配器(Adapters)在边界实现这些端口(REST、JPA、消息队列)。这种架构让可测试性体现在:领域逻辑可脱离基础设施完全测试(纯内存、无 Spring/数据库),适配器可分别用契约测试/集成测试验证依赖替换容易(换 DB、换框架不影响领域)。例如六边形架构中,领域服务只依赖 OrderRepository 接口,测试注入内存实现即可,无需真实数据库。可验证设计还要求依赖方向避免反向(领域不 import 基础设施)、边界抽象(用接口而非具体类)、副作用聚合到边界。落地时配合 TDD、DI、纯函数,让"可测试性"成为架构的一等公民。

可验证设计的本质是"把可测试性 built into 架构"而非事后补救。Hexagonal/Clean/Onion 通过"依赖倒置 + 端口抽象"把核心业务与外部技术解耦,从而让核心逻辑可以脱离框架、DB、网络独立测试——这是"可测试性设计"的架构保障。它让"单元测试覆盖核心逻辑"成为可能,也让"替换依赖"(测试替身、换技术栈)变得廉价。可验证设计是"高质量测试"的根基,因为它决定了"核心逻辑是否可测"这个前提。

// 领域层:只依赖端口(接口),不依赖基础设施
public interface OrderRepository {
    Optional<Order> findById(String id);
    void save(Order order);
}

public class OrderService {
    private final OrderRepository repo;   // 端口注入
    public OrderService(OrderRepository repo) { this.repo = repo; }
    public Order get(String id) {
        return repo.findById(id).orElseThrow(() -> new NotFoundException(id));
    }
}

// 测试:注入内存实现,无需数据库
@Test
void domainLogicIsolated() {
    OrderService svc = new OrderService(new InMemoryOrderRepository());
    assertThrows(NotFoundException.class, () -> svc.get("nope"));
}
#
★★★

5. 纯函数(pure function)的可测试性中无副作用、无全局状态

请说明纯函数(Pure Function)的可测试性,包括无副作用与无全局状态带来的优势?

  • 纯函数的定义(无副作用、无全局状态、引用透明)
  • 纯函数测试的确定性与简单性
  • 纯函数与副作用分离的实践

纯函数(Pure Function)满足两个条件:相同输入必得相同输出(引用透明),且不产生副作用(不修改外部状态、不做 I/O、不依赖全局可变状态)。可测试性因此极强:确定性——任何输入输出可预测,无需 setup、无需控制环境、不依赖执行顺序,天然可并行;简单性——测试只需 assertThat(f(input)).isEqualTo(expected),无需 mock、无需清理数据库、无需处理时序;隔离性——单个函数可独立测试,失败定位精确。相比之下,有副作用的函数(读文件、写日志、改全局)测试困难且脆弱。实践中应把副作用推到边界:核心业务逻辑保持纯函数化(如计算、校验、规则),把 I/O、网络、持久化隔离在外层或通过注入接口处理。测试要点:对纯函数用"输入-输出"表驱动测试(参数化测试)覆盖多组输入;对含副作用的函数,用替身隔离副作用后仍可测纯逻辑部分。纯函数也是属性测试、函数式组合的理想对象。

纯函数的可测试性来自"无副作用、无全局状态"——这两点消除了测试中所有不确定性来源(顺序、环境、共享状态、I/O)。纯函数测试是最廉价的测试形式:一次断言、无依赖、可并行、可复用。它把"可测试性"从"测试技巧"提升为"代码结构保证"。因此"纯函数优先 + 副作用隔离"是提升可测试性的核心策略。

// 纯函数:无副作用、无全局状态
BigDecimal discount(BigDecimal amount, boolean vip) {
    return vip ? amount.multiply(new BigDecimal("0.8")) : amount;
}

// 参数化测试:多组输入,确定性断言
@ParameterizedTest
@CsvSource({"100,true,80", "100,false,100"})
void discountIsDeterministic(BigDecimal amount, boolean vip, BigDecimal expected) {
    assertEquals(expected, discount(amount, vip));
}
#
★★★

6. 面向接口的设计(interface-oriented design)中 mock vs stub 的实现替换

请说明面向接口的设计(interface-oriented design)如何支持测试,以及其中 mock 与 stub 的实现替换边界?

  • 面向接口设计的解耦原则
  • 测试时用 stub/mock 替换接口实现
  • 选择 stub 还是 mock 的边界

面向接口的设计指代码依赖抽象接口而非具体类,实现了"依赖倒置"——调用方与实现解耦。这使测试能在接口处替换实现:注入 stub(提供数据)、fake(轻量实现)或 mock(验证交互),无需改动被测代码。替换的边界取决于测试意图:只需"让流程走通、提供数据"时用 stub(预先定义返回值);需要验证"是否按预期调用了接口、参数是否正确"时用 mock(记录并断言交互)。面向接口设计让"被测类"与"依赖实现"解耦,测试时 new RealService() 换成 new StubService()mock(Service.class) 即可。实践要点:接口应面向行为/契约(方法语义清晰),而非类的一个个 getter;测试优先用真实实现或 fake,对外部边界(网络、DB、第三方)才用 mock;mock 用于验证"交互契约"(如"保存调用了一次"),stub 用于"提供数据"。避免面向接口设计退化为"为每个类造接口"的过度设计——接口应有真实复用/替换价值。

面向接口设计的可测试性核心是"可替换性":接口是"替换点",测试在替换点上注入替身。stub 与 mock 的边界是**"状态验证 vs 行为验证**"——stub 提供数据(状态),mock 验证交互(行为)。选择时先问"我要验证什么":验证结果用 stub,验证交互用 mock。接口设计让替换廉价,是测试替身技术得以应用的前提。

interface EmailSender { void send(String to, String body); }

class OrderService {
    private final EmailSender sender;
    OrderService(EmailSender s) { this.sender = s; }
    void ship(Order o) { sender.send(o.getEmail(), "已发货"); }
}

// stub:提供数据,不验证交互
EmailSender stub = (to, body) -> {};

// mock:验证交互
EmailSender mock = mock(EmailSender.class);
new OrderService(mock).ship(order);
verify(mock).send("a@b.com", "已发货");   // 行为验证
#
★★★

7. 领域逻辑与技术细节的分离(Hexagonal)中 domain 不依赖 infrastructure

请说明六边形架构(Hexagonal)中领域逻辑与技术细节的分离,以及 domain 不依赖 infrastructure 的测试价值?

  • 六边形架构的端口-适配器结构
  • domain 不依赖 infrastructure 的依赖方向
  • 该分离带来的可测试性价值

六边形架构(Hexagonal / Ports & Adapters)把系统分为领域核心(domain)外围技术细节(infrastructure),通过端口(Ports)适配器(Adapters)连接。领域核心只依赖端口接口(如 OrderRepositoryPaymentGateway),不 import 任何基础设施(不依赖 JPA、Spring、REST、数据库厂商)。适配器在边界实现这些端口(JPA Repository、REST Controller、消息 Listener),把外部技术翻译成领域能用的接口。关键约束是依赖方向:domain 指向端口的接口,适配器反向依赖 domain 的接口,基础设施永不反向渗透进 domain。这个分离带来巨大可测试性价值领域逻辑可完全脱离基础设施测试——领域服务只依赖端口接口,测试注入内存实现即可,无需数据库、无需 Spring 容器、无需启动服务,测试快、确定、隔离;适配器可单独用集成/契约测试验证(如 JPA 适配器用 Testcontainers 测真实 DB);换技术栈不影响领域(从 JPA 换 MyBatis、从 REST 换 gRPC,领域逻辑不动)。实践上,用"仓库(Repository)接口"隔离数据访问、用"服务接口"隔离外部调用,用包结构(domain / application / infrastructure)强制依赖方向。这让"核心复杂逻辑"成为最容易测试的部分。

domain 不依赖 infrastructure 的本质是依赖倒置——把"技术细节"变为"可替换的适配器",领域核心成为纯逻辑。可测试性因此被"架构性"保证:领域逻辑不碰数据库、框架、网络,天然可单元测试。这是"可测试性设计"的典范——不是靠测试技巧,而是靠依赖方向从结构上保证核心逻辑可测、可替换、可快速验证。它让"测试领域逻辑"与"测试基础设施"解耦,各自用最合适的测试方式。

// domain:只依赖端口,不依赖 infrastructure
public interface OrderRepository {
    Optional<Order> findById(String id);
}

public class OrderService {
    private final OrderRepository repo;
    public OrderService(OrderRepository repo) { this.repo = repo; }
    public Order get(String id) {
        return repo.findById(id).orElseThrow(() -> new NotFoundException(id));
    }
}

// infrastructure:适配器实现端口,依赖 JPA
@Repository
public class JpaOrderRepository implements OrderRepository {
    @Override public Optional<Order> findById(String id) {
        return jpaRepo.findById(id).map(Order::fromEntity); // 翻译
    }
}

// 测试:domain 用内存实现,不依赖 infrastructure
@Test
void domainIsolatedFromInfra() {
    OrderService svc = new OrderService(new InMemoryOrderRepository());
    assertThrows(NotFoundException.class, () -> svc.get("x"));
}
#
★★★

8. Mock 过度使用的反模式中测试与实现细节耦合、重构时大面积失效,如何用真实依赖与契约测试替代?

请说明 Mock 过度使用的反模式(测试与实现细节耦合、重构时大面积失效),以及如何用真实依赖与契约测试替代?

  • Mock 过度使用的反模式表现(耦合实现细节、重构脆弱)
  • 真实依赖与 fake 的替代价值
  • 契约测试替代跨服务 mock 的方法

Mock 过度使用是常见反模式:当测试对内部协作对象(同进程内的服务、DAO、helper)大量 mock,并断言其内部调用细节(调用次数、参数顺序、内部方法)时,测试就与实现细节耦合——因为断言的是"实现怎么做的",而非"行为结果是怎样的"。后果是重构时大面积失效:只要内部实现变了(比如把两次调用合并成一次、改了参数传递方式),即使功能正确,测试也会失败,导致"测试阻碍重构"。此外,过度 mock 会掩盖真实集成问题(stub 的返回值可能与真实依赖不一致),并让测试失去"验证真实行为"的价值。替代方案:1)优先用真实依赖或 fake——对同进程内的逻辑,用真实实现或轻量 fake(内存实现),让测试验证真实行为;2)聚焦契约断言——mock 只用于"外部边界"(外部服务、DB、网络),且只断言"契约"(调用了边界、参数正确),不断言内部实现步骤;3)跨服务边界用契约测试(Pact)替代 mock——由消费者定义契约、Provider 验证,取代"各写各的 mock"导致的不一致;4)Assert 结果而非流程——尽量断言最终状态/返回值,而非内部调用序列。判断标准:重构时若测试是否也跟着改,若大量测试因重构而碎,说明 mock 过度

Mock 过度使用的根源是"把实现细节当契约"。测试的价值在于验证"行为契约",而非"内部实现"。当 mock 断言内部调用时,测试就从"行为验证"退化为"实现快照",与代码强耦合。解决方向是**"提升测试的抽象层级**":把断言从"内部步骤"提升到"对外行为与结果",用真实依赖/fake 验证真实行为,用契约测试验证跨服务边界。这样重构时保持行为不变,测试依然稳定,实现与测试解耦。

// 过度 mock(反模式):断言内部调用细节
OrderService svc = new OrderService(mockRepo, mockPayment);
// 重构后内部调用变化,测试也随之失效

// 改善:用 fake 验证真实行为
OrderService svc = new OrderService(new InMemoryOrderRepo(), new FakePaymentGateway());
Order result = svc.place(order);
assertEquals(OrderStatus.PAID, result.getStatus()); // 断言结果而非内部流程

// 跨服务边界:用契约测试验证,而非 mock 各自为政
#
★★

9. 生产验证的"A/B 测试"(A/B test)中统计显著性的实验

请说明生产验证中的 A/B 测试(A/B Test)原理,以及如何通过统计显著性设计实验?

  • A/B 测试的原理(对照组/实验组、随机分组)
  • 统计显著性(p 值、样本量、置信区间)
  • 工程落地(feature flag、度量、避免陷阱)

A/B 测试是一种生产验证方法:把用户随机分到对照组(A)实验组(B),分别使用旧版本与新版本,对比两者的关键指标(转化率、留存、点击率、耗时),以数据驱动决定是否采用新方案。它属于"右移"验证,用于验证"业务效果"而非"功能正确性"。统计显著性是 A/B 测试的科学支撑:其核心是判断观察到的差异是否由真实效果引起,而非随机波动。关键要素包括:原假设/备择假设(默认无差异)、p 值(在假设无差异下,观察到当前或更极端结果的概率,通常 p < 0.05 认为显著)、样本量(需足够大才能检测到预定大小的效果,可用功效分析计算)、置信区间(估计真实效果的范围)、功效(power)(能检测到真实效果的概率,通常 ≥ 0.8)。工程落地要点:随机分组(确保组间可比、避免选择偏差)、feature flag控制灰度放量、避免多重比较与中途窥探(peeking,会抬高假阳性)、监控组间基线差异(检查随机化是否成功)、运行足够时长以覆盖周期性(周末/工作日)。陷阱:样本量不足导致假阴性、p 值滥用、指标被污染(如渲染测试用户)。A/B 测试与金丝雀/灰度不同:金丝雀验证"稳定性",A/B 验证"业务效果"。

A/B 测试的价值在于"用数据验证决策",统计显著性保证"差异不是偶然"。它把"生产验证"从"功能是否正常"提升到"业务是否更好",是增量式产品迭代的基石。核心是把控实验设计的严谨性(样本量、随机化、显著性水平、避免窥探),否则会得出错误结论。工程上需配合 feature flag 与完善的度量体系。

#
★★

10. 可测试的 ID 生成(testable ID)中 IDGenerator 接口

请说明如何通过 IDGenerator 接口设计可测试的 ID 生成,以及相关测试方法?

  • IDGenerator 接口抽象(隔离 ID 生成策略)
  • 测试时注入可控 ID 生成实现
  • 随机/唯一 ID 的确定性测试

业务中常生成 ID(UUID、雪花 ID、自增 ID),直接调用真实生成器会带来两个测试问题:不确定(UUID/雪花 ID 每次不同,无法断言具体值)、依赖(可能依赖数据库、时钟、机器)。可测试的 ID 生成通过定义 IDGenerator 接口隔离生成策略:interface IdGenerator { String next(); },生产环境用 SnowflakeIdGenerator/UuidIdGenerator 实现,测试环境注入可控实现(如预定义序列的 Stub 或可指定返回值的 fake)。这让测试确定性地断言生成的 ID:注入 (() -> "fixed-id") 或顺序返回的生成器,被测代码使用的 ID 就可预期。同时接口抽象使换生成策略不影响业务代码(从雪花换随机、换无冲突方案),也便于测试极端场景(如 ID 冲突、重复 ID、超长 ID)。测试要点:用可控 ID 生成器验证"业务正确使用 ID"(如把 ID 传给下游、存入存储);用真实生成器(如 UUID)验证"格式/唯一性"(如格式正则、批量不重复);对依赖时钟/机器的生成器,用注入 Clock 或固定 source 控制。工程实践常把 IdGenerator 作为依赖注入,而非在业务里直接 new UUID()

可测试 ID 生成的核心是把"如何生成"抽象为接口、把"生成实现"可替换。直接调用静态工具(UUID.randomUUID())不可替换,测试无法控制;接口注入后,测试注入可控实现,实现确定性与可断言。这也符合"面向接口、依赖注入"的可测试性设计原则。对真实生成器(Hash/snowflake)可单独用"唯一性、格式、单调性"等属性测试验证。

interface IdGenerator { String next(); }

class OrderService {
    private final IdGenerator ids;
    OrderService(IdGenerator ids) { this.ids = ids; }
    Order create() { return new Order(ids.next()); }
}

@Test
void controllableIdIsTestable() {
    IdGenerator stub = () -> "ORDER-001";           // 可控实现
    OrderService svc = new OrderService(stub);
    assertEquals("ORDER-001", svc.create().getId()); // 确定性断言
}
#
★★

11. 可测试的时间(testable time)中 Clock 接口、@ClockProvider

请说明如何通过 Clock 接口/ClockProvider 设计可测试的时间,以及相关测试方法?

  • Clock 接口抽象(隔离时间来源)
  • 测试时注入固定/可控时钟
  • 时间相关逻辑(超时、过期、调度)的确定性测试

业务常依赖当前时间(超时判断、过期、生成时间戳、调度执行),直接调用 System.currentTimeMillis()new Date() 会带来不可测试问题:结果依赖真实时钟,测试无法复现"一段时间后"的状态,且测试运行时间影响结果。可测试的时间通过 Clock 接口抽象时间来源:Clock(Java 的 java.time.Clock 或自定义 TimeProvider)注入到业务中,生产用系统时钟(Clock.systemDefaultZone()),测试注入固定时钟Clock.fixed(instant, zone))或可控时钟(可推进的 fake clock)。这让时间相关逻辑确定可测:注入固定时间,断言"此时是否过期/未过期";注入可控时钟(mutableClock.advance(seconds))模拟"过了 5 分钟",验证超时/过期/调度的行为。Spring 中可用 @ClockProvider 类似的 Bean 工厂提供统一时钟,测试时替换。实践要点:业务代码不直接读系统时间,而是通过注入的 Clock 获取 now();测试用 Clock.fixed 固定时间点、用可推进时钟模拟时间流逝、用 @TestedTime/参数化验证边界(如恰好到期)。这让涉及时间的高线逻辑(token 过期、限流、定时任务)可精确、可复现地测试。

可测试时间的核心是"把时间来源抽象为可注入的 Clock",把"现在是什么时刻"从系统常量变成可控制变量。测试通过注入固定/可控时钟,把时间逻辑变成确定性输入,从而精确测试边界(过期、超时)。这与"可测试的随机"同一思路:把不确定源抽象注入。它是处理"时间相关逻辑"可测试性的标准手法。

// 业务:通过注入的 Clock 获取时间,不直接读系统时钟
class TokenService {
    private final Clock clock;
    TokenService(Clock c) { this.clock = c; }
    boolean isExpired(Token t) { return t.expiresAt().isBefore(clock.instant()); }
}

@Test
void timeIsControllable() {
    Instant fixed = Instant.parse("2026-01-01T00:00:00Z");
    TokenService svc = new TokenService(Clock.fixed(fixed, ZoneOffset.UTC));
    Token expired = new Token(fixed.minusSeconds(10)); // 十秒前过期
    assertTrue(svc.isExpired(expired));
}
#
★★

12. 可测试的数据库(testable DB)中 Repository 接口、Testcontainers

请说明如何通过 Repository 接口与 Testcontainers 实现可测试的数据库,以及相关测试方法?

  • Repository 接口抽象(隔离数据访问)
  • 内存 DB 与 Testcontainers(真实 DB 容器)的测试
  • 事务/回滚与数据隔离

数据库相关测试的可测试性有两个层面:抽象层真实层Abstract 层:通过 Repository 接口把数据访问抽象化,业务代码只依赖接口,测试时用内存实现(fake/内存 Map)或内存数据库(H2、SQLite)验证领域逻辑,无需真实 DB,快且确定。真实层:验证 SQL/映射/兼容性时,用 Testcontainers在测试中启动真实数据库容器(如 PostgreSQL、MySQL 的 Docker 容器),执行真实 SQL,验证与生产一致的数据库行为,避免"内存 DB 与生产行为不一致"的陷阱。Testcontainers 提供 JdbcTestContainer 动态端口、@Container 生命周期管理、@Testcontainers 集成。实践要点:领域逻辑测试用 Repository 接口 + 内存实现(不碰 DB);集成测试用 Testcontainers 跑真实 DB(验证 SQL/索引/事务/约束);每个测试数据隔离——用事务回滚(@Transactional + rollback)、@BeforeEach cleanup 或独立 schema,避免测试间数据污染与顺序依赖;用 docker-compose 或固定容器管理环境。选择依据:测试意图——验证业务逻辑用内存/接口替身,验证数据访问正确性用 Testcontainers 真实 DB。

可测试数据库的精髓是"分层":用 Repository 接口隔离,让领域逻辑测试不依赖真实 DB(快、确定);用 Testcontainers 在真实 DB 上验证数据访问(准、兼容)。内存 DB 快但可能与生产行为有偏差,Testcontainers 真实但较重,二者互补。数据隔离(回滚/清理)保证测试确定性,避免共享库的顺序依赖。这是"可测试数据库"的标准工程实践。

// Repository 接口:业务只依赖接口
interface OrderRepository { Optional<Order> findById(String id); }

// 单元测试:内存实现,不碰数据库
@Test
void domainWithoutDb() {
    OrderService svc = new OrderService(new InMemoryOrderRepository());
    assertThrows(NotFoundException.class, () -> svc.get("x"));
}

// 集成测试:Testcontainers 真实数据库
@Testcontainers
class OrderRepoIT {
    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:15");
    @Test
    void sqlAgainstRealDb() {
        // 用 db 的 JDBC URL 驱动真实 SQL
    }
}
#
★★

13. 可测试的网络(testable network)中 HttpClient 接口

请说明如何通过 HttpClient 接口设计可测试的网络调用,以及相关测试方法?

  • HttpClient 接口抽象(隔离真实网络)
  • 测试时注入可控 HTTP 客户端(stub/mock/测试服务器)
  • 处理超时、重试、错误响应的测试

业务常调用外部 HTTP 服务,直接使用真实 HttpClient 会带来不可测试问题:依赖真实网络(慢、不稳定、不可控)、外部服务不可用导致测试失败(flaky)、无法复现各种响应。可测试的网络通过 HttpClient 接口抽象网络调用:interface HttpClient { Response get(String url); },生产用真实实现(如 RealHttpClient 包装 JDK HttpClient/RestTemplate),测试注入可控实现。测试替换方式:1)stub/fake——注入返回预定义响应的实现(返回 200+JSON、404、500、超时异常),确定性覆盖各种分支;2)mock——验证"调用了哪个 URL、方法、参数";3)测试服务器——用 WireMock(本地 mock HTTP 服务器)或 OkHttp MockWebServer 启动真实 HTTP 服务器,按需返回响应,验证序列化/反序列化与实际请求;4)契约测试(Pact)——跨服务验证请求/响应契约。测试要点:用可控客户端覆盖成功、错误、超时、重试、熔断等场景;用 MockWebServer 断言实际发出的请求(URL、header、body);验证超时与重试逻辑(注入会抛超时的 stub,断言重试次数与退避)。网络抽象也可配合超时策略、重试策略注入,使测试能控制超时/重试行为。

可测试网络的核心是"把网络调用抽象为接口、把真实网络替换为可控实现",从而消除外部依赖的不确定性。stub 覆盖分支、MockWebServer 验证真实请求、WireMock 模拟复杂响应、契约测试验证跨服务契约,四者配合提供完整的网络测试能力。它让网络调用逻辑(重试、超时、错误处理)可确定、可复现地测试。

interface HttpClient { String get(String url); }

class UserClient {
    private final HttpClient http;
    UserClient(HttpClient h) { this.http = h; }
    User getUser(String id) {
        String body = http.get("/users/" + id);
        return parse(body);   // 解析
    }
}

// stub:返回可控响应
HttpClient stub = url -> "{\"id\":\"1\",\"name\":\"Alice\"}";
assertEquals("Alice", new UserClient(stub).getUser("1").getName());

// MockWebServer:验证实际请求
MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse()
    .setBody("{\"id\":\"1\",\"name\":\"Alice\"}"));
#
★★

14. 可测试的并发与异步(testable async)中如何抽象线程池、调度器与超时策略,以便在测试中控制并发时序?

请说明如何通过抽象线程池、调度器与超时策略,使并发与异步逻辑在测试中可控制时序?

  • 线程池/调度器/超时策略的抽象注入
  • 测试中控制并发时序(同步执行、可控调度)
  • 异步超时/竞态的测试方法

并发与异步逻辑(线程池、调度、超时、竞态)因为时序不可控而难以测试:真实线程池调度不确定、超时依赖真实时间、竞态依赖具体调度顺序。可测试的并发通过抽象注入把控制点暴露出来:线程池抽象为 Executor 接口(ExecutorService),测试注入同步执行器Runnable::run 直接在当前线程执行)让异步变同步、顺序确定;调度器抽象为 Scheduler/Delayer 接口,测试注入可控调度器(记录收到任务、手动触发)或 Delay 计数器,无需真实等待;超时策略抽象为 TimeoutPolicy/Clock,测试注入可控时钟或固定超时,验证超时边界。测试方法:同步执行器让竞态可控(确定性复现);可控调度器手动触发任务,验证"到点任务执行";注入固定时钟验证超时/过期;对真实竞态用Awaitility 轮询等待结果稳定;用信号量/CountDownLatch控制线程同步。要避免的:测试里用固定 sleep 猜测(flaky);依赖真实线程池的真实时序(不稳定)。工程上常用 Executor 注入(生产用池、测试用直执行)、ScheduledExecutorService 可替换、CompletableFuture 与可控调度配合。测试目标是"把时序变成可控制变量",从而确定性验证并发正确性。

并发可测试性的核心是"时序可控"——通过把线程池、调度器、超时抽象成可注入接口,测试把真实并发时序替换为可控时序(同步执行、手动触发、固定时钟)。这消除了并发测试的根本障碍(不确定性)。用同步执行器验证逻辑正确性、用可控调度器验证调度行为、用等价性测试(同步 vs 并发结果一致)验证并发安全,是标准的并发测试手法。

class CacheService {
    private final Executor executor;
    CacheService(Executor e) { this.executor = e; }
    void refresh() { executor.execute(this::doRefresh); }
}

// 测试:注入同步执行器,异步变同步,时序确定
@Test
void asyncIsSynchronous() {
    CacheService svc = new CacheService(Runnable::run); // 直执行
    svc.refresh();
    assertTrue(cache.isFresh()); // 立即断言,无需等待
}
#
★★

15. 可测试的外部副作用(testable side effects)中邮件、短信、支付回调等外部副作用如何通过端口抽象隔离并在测试中替换?

请说明邮件、短信、支付回调等外部副作用如何通过端口(Port)抽象隔离,并在测试中替换?

  • 外部副作用(邮件/短信/支付)的端口抽象
  • 测试时替换为 fake/stub(记录型替身)
  • 验证副作用"被正确触发"与"参数正确"的方法

邮件、短信、支付回调等外部副作用(Side Effects)通常牵涉不可控的外部服务:调用真实邮件/短信 API 慢、贵、不稳定,且测试环境不可用。可测试的外部副作用通过端口(Port)抽象隔离:定义接口(如 NotifierPaymentGatewaySmsSender)作为内部业务与外部世界的边界,业务只依赖端口,生产用适配器实现真实调用,测试注入记录型替身(Recording Fake/Spy)——记录"被调用的参数",同时不真正发送。测试时可断言:1)副作用被正确触发(如"下单成功应发送通知");2)参数正确(收件人、金额、内容);3)副作用未触发(如"失败不应发送")、触发次数(避免重复发送)。方法:用 Spy/Fake记录调用(verify(sender).send(...)),或注入记录型 fake(自写 Map 记录调用)后断言调用记录。这使副作用逻辑(发不发、发什么、发几次)可确定地测试,且不会产生真实外部调用。工程实践:把副作用抽象为端口接口,业务依赖接口;测试用记录型替身验证"触发与参数";对真实外部系统(支付)用契约测试测试沙箱验证适配器本身。目标:副作用"何时触发、以何参数触发"是核心业务逻辑,可测试;而真实发送由适配器负责,单独测试

外部副作用可测试的关键是"端口抽象 + 记录型替身"。业务逻辑决定"是否触发副作用、以什么参数触发",这是值得测试的核心决策;而真实发送是外部适配器职责。通过端口注入记录型替身,测试把"副作用触发决策"变成可断言的行为,同时避免真实外部调用。这体现了"把副作用隔离到边界、把决策逻辑保留在核心"的可测试性设计。

interface Notifier { void send(String to, String body); }

class OrderService {
    private final Notifier notifier;
    OrderService(Notifier n) { this.notifier = n; }
    void confirm(Order o) { notifier.send(o.getEmail(), "订单已确认"); }
}

// 记录型 fake:
class RecordingNotifier implements Notifier {
    List<String> sent = new ArrayList<>();
    public void send(String to, String body) { sent.add(to + ":" + body); }
}

@Test
void sideEffectIsRecorded() {
    RecordingNotifier n = new RecordingNotifier();
    new OrderService(n).confirm(new Order("a@b.com"));
    assertEquals(1, n.sent.size());
    assertTrue(n.sent.get(0).contains("a@b.com"));
}
#
★★

16. 可测试的配置与特性开关中配置源抽象与 feature flag 注入如何使测试覆盖多配置组合?

请说明配置源抽象与 feature flag(特性开关)注入如何使测试能覆盖多配置组合?

  • 配置源抽象(环境变量/配置文件/注册中心)
  • feature flag 注入与测试覆盖多组合
  • 配置组合的测试方法(参数化、矩阵)

业务行为常受配置(超时、阈值、开关)与特性开关(feature flag)影响,直接读取全局配置(System.getProperty、静态常量、环境变量)会让测试难以控制。可测试的配置通过配置源抽象ConfigSource/Settings 接口)隔离配置读取:生产从环境变量/配置文件/注册中心读取,测试注入内存配置Map 配置),可自由设置任意值。feature flag同样抽象为可注入的开关(FeatureFlags 接口),测试注入可编程 flag(如 new InMemoryFeatureFlags(Map.of("new-ui", true))),从而覆盖"开/关"两种状态。这让测试能覆盖多配置组合:1)参数化测试——用 @ParameterizedTest 遍历多组配置(不同阈值、不同开关);2)配置矩阵——组合不同 feature flag 与配置值,验证行为矩阵;3)边界测试——设置临界配置(超时 0、阈值边界、开关全开/全关)。测试方法:注入内存配置后,断言不同配置下的行为差异;用 @CsvSource@MethodSource 提供配置组合;用独立配置对象避免全局可变状态。工程实践:配置与 flag 都通过接口注入(而非直接读全局),测试用内存实现覆盖组合,生产用真实配置源。这使"配置驱动的行为"可确定、可全面地测试。

配置与 feature flag 可测试的核心是"配置源抽象 + 注入"。把配置读取从全局静态变为可注入接口,测试就能自由控制配置值,从而覆盖"多配置组合"。这很重要,因为配置驱动的分支(如 flag 开/关、不同阈值)如果不测试,会成为未覆盖的隐藏分支。参数化 + 矩阵覆盖,让配置组合行为被全面验证,且避免共享全局配置带来的顺序依赖。

interface ConfigSource { String get(String key); }
interface FeatureFlags { boolean isEnabled(String name); }

class BillingService {
    private final ConfigSource cfg;
    private final FeatureFlags flags;
    BillingService(ConfigSource c, FeatureFlags f) { cfg = c; flags = f; }
    BigDecimal fee() {
        BigDecimal rate = new BigDecimal(cfg.get("rate"));
        return flags.isEnabled("discount") ? rate.multiply(new BigDecimal("0.9")) : rate;
    }
}

@ParameterizedTest
@CsvSource({"0.1,true,0.09", "0.1,false,0.1", "0.5,true,0.45"})
void configCombination(String rate, boolean flag, String expected) {
    BillingService svc = new BillingService(
        Map.of("rate", rate)::get,            // 内存配置源
        Map.of("discount", flag)::get);       // 可编程 flag
    assertEquals(new BigDecimal(expected), svc.fee());
}
#
★★

17. 行为验证 vs 状态验证的取舍中何时用 spy/mock 验证交互,何时应断言结果状态?

请说明行为验证(Behavior Verification)与状态验证(State Verification)的取舍,以及何时用 spy/mock 验证交互、何时断言结果状态?

  • 行为验证(验证交互)与状态验证(断言结果)的区别
  • 何时用 spy/mock(外部边界、关键交互)
  • 何时断言结果状态(内部逻辑、可观察结果)

状态验证(State Verification)断言"被测对象经过操作后的结果状态"——返回值、对象字段、数据库状态、集合内容,即**"做了什么**";行为验证(Behavior Verification)断言"如何做的"——对象是否调用了某个依赖、以什么参数调用、调用几次,通常用 mock/spy验证交互。取舍原则优先状态验证——能断言可观察结果(返回值、状态)时,用状态验证,因为它更稳健、不耦合实现细节、重构时不易碎;行为验证仅用于需要验证"交互契约"的场景——当没有可观察结果、或验证"确与外部边界交互"(如调用了外部服务、记录了审计、发送了消息)时,用 spy/mock 验证交互。判断标准:被测的"行为"是否是可观察的"结果"。若结果可断言(如 service.getTotal() 返回正确值),用状态验证;若行为是"副作用"(如"应调用 paymentGateway.charge()"),用行为验证(spy/mock)。spy适用于"真实对象 + 记录调用"(既验证真实行为又验证交互),mock适用于"完全替换依赖 + 验证交互"。实践中过度使用行为验证是反模式(断言内部调用细节),应尽量用状态验证表达"结果正确",仅在必要的外部边界用行为验证。可通过守门测试(如"不应调用某方法")验证负数行为。

状态验证与行为验证的取舍,本质是"断言结果还是断言过程"。状态验证更稳健(不耦合实现),行为验证更精确(验证交互)。原则是**"能用状态验证就用状态验证,行为验证只在需要验证交互契约时用**"。因为状态验证测试重构更稳(实现怎么变不重要,结果对就行),行为验证则可能把测试绑定到实现细节。行为验证的适用场景是"无结果可断言"或"必须保证与外部边界交互正确"。

// 状态验证:断言结果状态
@Test
void stateVerification() {
    OrderService svc = new OrderService(stubRepo);
    Order o = svc.create(goods);
    assertEquals(OrderStatus.NEW, o.getStatus()); // 断言结果
}

// 行为验证:验证与外部边界的交互
@Test
void behaviorVerification() {
    PaymentGateway gateway = mock(PaymentGateway.class);
    new OrderService(gateway).pay(order);
    verify(gateway).charge(order.getCard(), order.getAmount()); // 验证交互
}
#

18. 可测试的随机(testable random)中 Random 接口、seed 可控

请说明如何通过 Random 接口与可控 seed 实现可测试的随机,以及相关测试方法?

  • Random 接口抽象(隔离随机源)
  • seed 可控与确定性
  • 随机行为(抽样、抖动、随机选择)的测试

业务有时依赖随机(如抽样、随机抖动、随机选择、生成随机数),直接调用 Math.random()new Random() 会让测试不确定——每次运行结果不同,无法断言。可测试的随机通过 Random 接口抽象随机源:interface RandomSource { int nextInt(int bound); },生产用 Random/SecureRandom 实现,测试注入可控实现seed 可控是另一手段:用有固定 seed 的 Randomnew Random(42))——同一 seed 产生同一随机序列,从而让测试确定性复现,断言特定随机选择结果。测试方法:1)注入可控随机源——返回预定值(如总是返回 0、末尾、特定索引),确定性断言"基于随机选择的逻辑";2)固定 seed——用 Random(42) 复现序列,验证分布/抽样逻辑;3)覆盖边界——注入随机源返回 0、bound-1、越界等边界值,验证随机选择逻辑在边界行为正确;4)统计验证——对随机性本身(如均匀分布)用大量样本做统计断言(如分布近似均匀)。工程实践:把随机源作为依赖注入(而非直接 Math.random()),业务代码可测;对需要安全的随机(token、加密)用 SecureRandom,但测试仍可注入可控源验证"使用了随机源"这一交互。目标:随机决策逻辑可测(选什么由随机源决定),而随机源本身(均匀性)单独验证。

可测试随机的核心是"把随机源抽象为可注入接口 + seed 可控"。随机源是"不确定性"的来源,注入可控随机源或固定 seed 后,随机决策逻辑变成确定性可测。这与"可测试的时间、ID"同一思路:把不确定源抽象注入。测试关注"业务如何利用随机值作出决策",而非随机值本身;随机源本身的统计性质用专门测试验证。

interface RandomSource { int nextInt(int bound); }

class Lottery {
    private final RandomSource rnd;
    Lottery(RandomSource r) { this.rnd = r; }
    String winner(String[] names) { return names[rnd.nextInt(names.length)]; }
}

// 可控随机源:确定性断言
@Test
void controllableRandom() {
    Lottery lot = new Lottery(bound -> 0);   // 总是返回 0
    assertEquals("Alice", lot.winner(new String[]{"Alice", "Bob"}));
}

// 固定 seed:确定性复现序列
@Test
void seededRandom() {
    Random r = new Random(42);
    int a = r.nextInt(100), b = r.nextInt(100);
    Random r2 = new Random(42);
    assertEquals(a, r2.nextInt(100));
    assertEquals(b, r2.nextInt(100));
}
#

19. 不可测试代码的信号中 new、静态方法、Singleton 与全局状态如何阻碍测试,重构优先级如何排?

请说明 new、静态方法、Singleton 与全局状态这些不可测试代码的信号如何阻碍测试,以及重构优先级如何排列?

  • 不可测试代码的信号(new、静态方法、Singleton、全局状态)
  • 它们如何阻碍测试(无法替换、共享状态、时序)
  • 重构优先级与迁就策略

以下几类代码是"不可测试的信号",它们通过阻碍"依赖替换"与"状态隔离"来妨碍测试:new 硬编码依赖——在被测代码里 new Xxx() 直接创建依赖,测试无法替换为 fake/mock,只能真实运行(慢、脆、不可控);静态方法——如 Util.getInstance()OrderService.getInstance(),静态调用无法被替换/注入,测试无法隔离;Singleton——全局唯一实例,测试无法创建独立替身,且单例常持有共享可变状态,导致测试间相互污染;全局状态——静态变量、全局配置、环境变量被测试共享,产生顺序依赖与非确定性。它们阻碍测试的机制:无法注入替身(new/静态/Singleton 让依赖固化)、共享可变状态(Singleton/全局状态导致顺序依赖与 flaky)、难以隔离(测试间相互影响)。重构优先级建议按"对测试阻塞程度 × 变更难度"排序:1)先解决"new 硬编码依赖"——改造成构造器注入,收益最大、最直接(测试能替换依赖);2)再解决"静态方法/单例"——改为实例方法 + 依赖注入,或引入接口;3)再治理"全局状态"——改为通过配置源/上下文注入,消除共享可变状态;4)最后处理"纯静态工具类"(无状态的纯函数静态方法,如 StringUtils)——这类本身可测(无状态),优先级最低。原则:先让"依赖可替换"(new/静态/Singleton),再让"状态可隔离"(全局状态);纯函数静态工具可保留。

这些信号共同指向"依赖不可替换 + 状态不可隔离",是测试障碍的根源。new、静态、Singleton 让"创建依赖"固化在代码里,测试无法注入替身;全局状态让"共享状态"不可控,产生顺序依赖。重构优先级的关键是**"先消除对测试阻塞最大的点**":构造器注入(解 new)是性价比最高的第一步;静态/单例次之;全局状态需要重构状态管理;纯函数静态工具(无状态)不阻碍测试,可不动。这个优先级让"测试可写"尽早达成,逐步提升可测试性。

// 不可测试:new 硬编码依赖
class OrderService {
    PaymentGateway gateway = new RealPaymentGateway(); // 无法替换
}

// 可测试:构造器注入
class OrderService {
    private final PaymentGateway gateway;
    OrderService(PaymentGateway g) { this.gateway = g; } // 可注入替身
}