Skip to content

可测试性与 Arrange-Act-Assert ​

T1-006必学进阶约 60 分钟JDK 21测试专项 A:单元测试 · 测试思维

1. 本节定位 ​

《JUnit 5:测试结构与断言》、《AssertJ 与断言语义化》、《Mockito:Mock、Stub 与 Verify》 教了工具。本节讲一个更靠前的问题:有些代码根本测不了。

"测不了"通常不是测试技巧不够,而是设计把不确定性藏在了内部。本节用 Clock 这个具体例子把它讲透,并给出组织测试步骤的通用结构:AAA。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出可测试性的两个维度(可控性、可观测性);
  • 识别四种"不可测试"的信号,并给出改法;
  • 用注入 Clock 的方式让"时间相关逻辑"变得可测;
  • 用 AAA 结构组织测试,做到"一个测试一个动作";
  • 说出 Given-When-Then 与 AAA 的对应关系;
  • 判断哪些"为测试而做的设计"是值得的、哪些是过度设计。

3. 前置知识检查 ​

  • 知道依赖注入对测试的价值吗?(T1-003 的 5.3 节)
  • 会写 JUnit 5 测试吗?(T1-001)

4. 为什么需要这个知识 ​

看一个"周末促销"的需求:

java
public class OrderReporter {
    public String report(long amountInCents) {
        LocalDate today = LocalDate.now();          // 时间在内部获取
        if (today.getDayOfWeek() == DayOfWeek.SUNDAY) {
            return "周日促销:" + amountInCents + " 分";
        }
        return "订单金额:" + amountInCents + " 分";
    }
}

这段代码有什么问题?

  • 写法上没问题:逻辑清晰、命名合理、功能正确;
  • 但测不了:LocalDate.now() 的值取决于你什么时候跑测试。
想测的场景需要什么条件
周日促销分支在周日跑测试
工作日分支在周一至周六跑测试

这意味着:一个测试能覆盖哪个分支,取决于运行的那天是星期几。开发者在周三写了"工作日"的测试,它在周日就会变红——这就是"不可重复"的测试(违反 FIRST 里的 R,T1-007)。

根因不在测试,在设计:可控性被藏进了方法内部。

5. 核心概念 ​

5.1 可测试性的两个维度 ​

维度含义对应的问题
可控性(Controllability)能让被测代码进入任意想测的状态吗?"怎么造出'周日'这个状态?"
可观测性(Observability)能观察到代码做了什么吗?"怎么知道它发了通知?"

四个"不可测试"的信号(对应"可控性/可观测性"缺失):

信号表现为什么难测
内部 new 依赖new JdbcUserRepository(...) 写在方法里无法替换成替身
静态方法调用LocalDate.now()、IdUtil.next()无法注入,行为固定
隐藏的不确定性时间、随机数、UUID、系统属性每次运行结果都不同
隐藏的全局状态单例、静态可变字段无法隔离,测试互相影响

可观测性的缺失通常是另一种问题:方法没有返回值,也没有副作用可供观察(比如只写日志)。这时要么加返回值,要么把"日志"本身作为一个可注入的依赖。

5.2 注入:可测试性的基础 ​

把"不可控的东西"变成构造参数:

java
public class OrderReporter {
    private final Clock clock;

    public OrderReporter(Clock clock) {          // 时间从外部注入
        this.clock = clock;
    }

    public String report(long amountInCents) {
        LocalDate today = LocalDate.now(clock);  // 用注入的时钟
        ...
    }
}

测试里就能随便"造时间":

java
private static Clock clockAt(String date) {
    return Clock.fixed(Instant.parse(date + "T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
}

@Test
void testable_version_covers_both_branches() {
    OrderReporter sunday = new OrderReporter(clockAt("2026-09-20"));
    assertThat(sunday.report(10_000L)).isEqualTo("周日促销:10000 分");

    OrderReporter monday = new OrderReporter(clockAt("2026-09-21"));
    assertThat(monday.report(10_000L)).isEqualTo("订单金额:10000 分");
}

两个分支都被稳定覆盖,而且测试跑在任何一天都一样。

为什么用 java.time.Clock 而不是自己定义 TimeProvider 接口:Java 8 之后标准库已经内置了这个抽象(LocalDate.now(clock)、Instant.now(clock) 都接受 Clock),不需要自己造轮子。

注入方式的优先级:

方式推荐度说明
构造器注入★★★依赖一目了然、对象创建后不可变、@InjectMocks 也优先用它
方法参数注入★★适合"只在某次调用时需要"的依赖(如 now(clock))
setter 注入★会引入"半初始化状态",能用构造器就别用
字段注入(@Autowired)✗无法在纯单元测试里替换,只能靠容器

5.3 哪些"为测试而做的设计"值得 ​

值得(不确定性的来源):

  • 时间(Clock)、随机(Random 或 RandomGenerator)、UUID 生成;
  • 外部系统(数据库、HTTP 客户端、消息队列);
  • 环境相关(文件系统、系统属性)。

不值得(为了"能 mock"而增加间接层):

java
// 过度设计:唯一的实现,且没有不确定性
interface UserNameFormatter {
    String format(String name);
}

判断标准:

这个东西的值/行为在测试里可控吗? 不可控 → 注入;可控(纯函数、固定值)→ 直接调用。

5.4 AAA:一个测试的三段式 ​

java
@Test
void rename_notifies_user() {
    // Arrange(准备):构造被测对象与前置状态
    UserRepository repository = mock(UserRepository.class);
    Notifier notifier = mock(Notifier.class);
    when(repository.findById("u1")).thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
    UserService service = new UserService(repository, notifier);

    // Act(执行):调用被测方法——只有这一个动作
    String result = service.rename("u1", "新名");

    // Assert(断言):验证可观察的结果
    assertThat(result).isEqualTo("新名");
    verify(notifier).send("u1", "您的名字已更改为 新名");
}

三条纪律:

纪律说明违反的信号
只有一个 Act一个测试只触发一次被测行为两个 service.xxx() 调用
Assert 只验证 Act 的结果不要把准备阶段的验证混进来断言了 mock 的初始状态
三段之间空行分隔让结构一眼可见十几行混在一起

关于"只有一个 Act"的例外:需要验证"顺序"或"累积"时可以有多个动作,但这时它应该是一个"场景测试",并且在方法名里说清(如 two_renames_save_twice)。

5.5 AAA 与 Given-When-Then ​

两者是同一结构的两种说法:

AAAGiven-When-Then含义
ArrangeGiven(假设)前置状态
ActWhen(当……时)触发的行为
AssertThen(那么)期望的结果

GWT 更适合"读起来像一句话"的命名:

java
@Test
void given_user_exists_when_renamed_then_notification_sent() { }

AAA 更适合"结构化注释"。选一种,团队内统一即可——本项目用 AAA(注释 + 空行)。

5.6 反例:违反 AAA 的三种写法 ​

java
// 反例一:多个 Act
@Test
void multiple_acts() {
    service.rename("u1", "新名一");
    service.rename("u1", "新名二");        // 第二次 Act:失败了不知道是哪一次引起的
    verify(repository, times(2)).save(any());
}

// 反例二:准备阶段就断言
@Test
void assert_in_arrange() {
    UserRepository repository = mock(UserRepository.class);
    when(repository.findById("u1")).thenReturn(Optional.of(user));
    assertThat(repository.findById("u1")).isPresent();     // 这不叫测试,叫自证
    // 真正的 Act 与 Assert 呢?
}

// 反例三:没有 Act(只验证构造)
@Test
void no_act() {
    UserService service = new UserService(repository, notifier);
    assertThat(service).isNotNull();                        // 零价值
}

反例一的问题值得展开:第一次 rename 可能已经让状态变成"新名一",第二次 rename 的行为就依赖于第一次的结果——测试不再是一个独立的场景。

6. 递进示例 ​

6.1 最小示例:注入时钟 ​

见 5.2 节。

6.2 业务示例:用 AAA 组织 ​

见 5.4 节。

6.3 反例:不可测试的版本 ​

java
static class BadOrderReporter {
    String report(long amountInCents) {
        LocalDate today = LocalDate.now();        // 内部取时间:不可控
        ...
    }
}

测试它的唯一办法:

java
@Test
void untestable_version_can_only_assert_generic_properties() {
    BadOrderReporter reporter = new BadOrderReporter();
    String result = reporter.report(10_000L);

    // 无法断言具体是哪个分支:今天是不是周日,决定了这个测试能覆盖到什么
    assertThat(result).contains("10000 分");
}

这个断言是"通用性质"(两个分支都满足它),它证明不了任何一个分支的逻辑是对的。

7. 完整可运行代码 ​

文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/TestabilityDemoTest.java

java
@DisplayName("可测试性:把不可控的依赖变成可控")
class TestabilityDemoTest {
    /** 不可测试的写法:自己在内部取当前时间 */
    static class BadOrderReporter {
        String report(long amountInCents) {
            LocalDate today = LocalDate.now();
            if (today.getDayOfWeek() == DayOfWeek.SUNDAY) {
                return "周日促销:" + amountInCents + " 分";
            }
            return "订单金额:" + amountInCents + " 分";
        }
    }

    /** 可测试的写法:时间从外部注入 */
    static class OrderReporter {
        private final Clock clock;

        OrderReporter(Clock clock) {
            this.clock = clock;
        }

        String report(long amountInCents) {
            LocalDate today = LocalDate.now(clock);
            if (today.getDayOfWeek() == DayOfWeek.SUNDAY) {
                return "周日促销:" + amountInCents + " 分";
            }
            return "订单金额:" + amountInCents + " 分";
        }
    }

    /** 构造一个"永远停在某一天"的时钟 */
    private static Clock clockAt(String date) {
        return Clock.fixed(Instant.parse(date + "T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
    }

    @Test
    @DisplayName("可测试版本:周日与工作日两个分支都能稳定测到")
    void testable_version_covers_both_branches() {
        OrderReporter sunday = new OrderReporter(clockAt("2026-09-20"));
        assertThat(sunday.report(10_000L)).isEqualTo("周日促销:10000 分");

        OrderReporter monday = new OrderReporter(clockAt("2026-09-21"));
        assertThat(monday.report(10_000L)).isEqualTo("订单金额:10000 分");
    }

    @Test
    @DisplayName("不可测试版本:只能断言「包含金额」这种通用性质")
    void untestable_version_can_only_assert_generic_properties() {
        BadOrderReporter reporter = new BadOrderReporter();

        String result = reporter.report(10_000L);

        // 无法断言具体是哪个分支:今天是不是周日,决定了这个测试能覆盖到什么
        assertThat(result).contains("10000 分");
        // 想稳定测到「周日促销」分支?只能改系统时间,或者等到周日。
    }
}

运行命令:

powershell
cd labs
./mvnw -pl testing-unit test -Dtest=TestabilityDemoTest

预期输出:

text
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.011 s
  -- in cn.kb.learn.testing.TestabilityDemoTest

两个测试的对比是本节的全部论证:

可测试版本不可测试版本
能测到的分支两个都测到了只能测到"今天对应的那个"
断言强度精确断言完整字符串只能断言"包含金额"
换一天跑结果不变可能变红
额外成本一个构造参数零

注意最后一行:"可测试写法"的代价只有一个构造参数——这不是"为测试而过度设计",而是把原本藏在方法里的依赖显式表达出来。

8. Debug 观察点 ​

  1. 在 testable_version_covers_both_branches 的两个 report 调用处分别断点,观察 LocalDate.now(clock) 返回的日期稳定地是 2026-09-20 / 2026-09-21——与"今天几号"无关;
  2. 在不可测试版本的 LocalDate.now() 处断点,观察它返回的是真实的当前日期——这就是不可控的来源;
  3. 把 clockAt("2026-09-20") 的时区改成 ZoneId.of("UTC"),观察结果是否变化(提示:Instant.parse("2026-09-20T02:00:00Z") 在 UTC 下是 09-20 02:00,仍是周日;但把小时数改到 23:00 后,时区就可能让它变成另一天)——这是"时区 bug"的典型来源;
  4. 在 AAA 结构的三段之间打上标记断点,体会调试时"准备/执行/断言"在时间上的分离。

9. 常见错误 ​

错误 1:用 Thread.sleep 代替可控时间 ​

java
@Test
void expires_after_30_minutes() throws InterruptedException {
    service.create("order-1");
    Thread.sleep(30 * 60 * 1000);                 // 睡 30 分钟
    assertThat(service.isExpired("order-1")).isTrue();
}

两个致命问题:① 测试要跑 30 分钟(违反 Fast);② 它验证不了"30 分钟"这个边界(只能验证"≥30 分钟后过期")。

修复:注入 Clock,用 clock.instant().plus(Duration.ofMinutes(29)) 精确测边界。

错误 2:测试私有方法 ​

java
Method method = Service.class.getDeclaredMethod("internalLogic");
method.setAccessible(true);                        // 反射测私有方法

问题:私有方法是实现细节(T1-012 的核心观点)。测它等于把测试绑死在实现上。

修复:通过公开方法测它产生的行为;如果公开方法测不到,说明这段逻辑应该被提取成一个独立的类(然后它就有了自己的公开接口)。

错误 3:为了可测性把一切都抽象成接口 ​

java
interface StringTrimmer { String trim(String input); }        // 过度设计

判断标准:这个类型有没有"不确定性"或多个实现? 没有就别抽象。测试不是设计的目的,它只是设计质量的体检。

错误 4:一个测试里多个 Act ​

见 5.6 节。修复:拆成多个测试,或者用参数化测试(T1-002)。

错误 5:在 Arrange 里放业务逻辑 ​

java
// 反例:准备阶段自己算了一遍期望值
long expected = items.stream().mapToLong(Item::price).sum() * (100 - discount) / 100;
assertThat(cart.total()).isEqualTo(expected);

问题:期望值不应该由"和被测代码同样的算法"算出来——否则两边一起错时测试还是绿的。

修复:期望值写具体的数字(9_000L),让它是"人算出来的、显式写下的"。

10. 动手实验 ​

目标:把一个"测不了的类"改造成"可测的"。

步骤:

  1. 运行第 7 节的命令,核对 2 个测试通过;
  2. 自己在 labs/testing-unit 里写一个 TicketService:
    • String status(String ticketId):如果距创建时间超过 24 小时返回 "EXPIRED",否则返回 "OPEN";
    • 第一版用 LocalDateTime.now()(不可测),然后试着为它写测试——记录你遇到了什么困难;
    • 第二版注入 Clock,为它写出 4 个测试:23 小时 59 分(未过期)、24 小时整(边界)、24 小时零 1 分、刚创建;
  3. 给 TicketService 加一个规则"周末不计入 24 小时"——用 FakeClock 前进到周末验证(提示:这需要 Clock 而非简单的固定时刻,可以用 Clock.offset);
  4. 用 AAA 注释整理第 2 步的 4 个测试,确保每个只有一个 Act;
  5. 写出一个"违反 AAA"的版本(多个 Act),说明它会带来什么问题。

验收方式:能说出第 2 步第一版遇到的困难,以及注入 Clock 后解决了什么。

扩展任务:给 TicketService 加一个"随机分配处理人"的功能(从 3 个人里随机选),写出可测的版本(提示:注入 RandomGenerator 或 Supplier<String>),并说明"为什么随机性必须被注入"。

11. 练习题 ​

基础题

  1. 判断:测不了的代码说明测试技巧不足。(对 / 错)
  2. 填空:可测试性的两个维度是 ______ 与 ;AAA 的三段是 、、。
  3. 简答:为什么注入 Clock 不算"为测试而过度设计"?

代码阅读题

java
public class PriceCalculator {
    public long finalPrice(long original, String couponCode) {
        long price = original;
        if ("SAVE10".equals(couponCode) && LocalDate.now().getDayOfWeek() != DayOfWeek.SUNDAY) {
            price -= 1_000;
        }
        return price;
    }
}
  1. 这个类有什么可测试性问题?
  2. 你会怎么改造它?改造后能多测出哪些场景?

编程题

  1. 改造一个"不可测试"的类:
    • 原代码:
java
public class SmsSender {
    public boolean send(String phone, String content) {
        if (content.length() > 70) {
            throw new IllegalArgumentException("短信内容不能超过 70 字");
        }
        return HttpClientFactory.get().post("https://sms.example.com/send", phone + ":" + content);
    }
}
  • 问题:HttpClientFactory.get() 是静态调用(不可替换);
  • 要求:改造为可注入的形式,并写出 3 个测试:内容超长抛异常、发送成功返回 true、网关返回失败时返回 false;
  • 说明:改造后"内容超长"这个场景根本不需要 HTTP 替身,为什么?

综合任务

  1. 为一段"订单超时自动取消"的逻辑设计可测试性方案:
    • 需求:下单后 30 分钟未支付则自动取消;取消时发通知;周末的 22:00 到次日 08:00 不执行取消(静默期);
    • 设计 OrderExpiryService 的依赖与构造方式,说明每一项为什么需要注入;
    • 列出你会写的测试场景(至少 6 个,含 3 个边界);
    • 用 AAA 结构写出其中 2 个测试;
    • 说明:哪些场景用 FakeClock 比用固定 Clock 更合适?为什么?
分级提示

提示 1(方向):第 4 题的关键是 LocalDate.now() 藏在方法里,导致"周日不减免"这个分支只在周日能测到。

提示 2(关键点):第 6 题的"内容超长"校验发生在 HTTP 调用之前,所以它不关心 HTTP 客户端是什么——这正是"早失败"带来的可测性红利。

提示 3(接近答案):第 7 题的"静默期判断"需要把时间推进到 22:00 之后,固定时钟做不到(它只能停在某一刻),需要 Clock.offset 构造的"可移动时钟"。

12. 参考答案 ​

基础题
  1. 错。"测不了"通常是设计的信号:依赖藏在内部、不确定性没有外露。"测试技巧不足"会让测试写得更别扭,但改不了根本问题。可测试性是设计属性,不是测试属性。
  2. 可控性 与 可观测性;Arrange(准备)、Act(执行)、Assert(断言)。
  3. 因为时间本身就是一个真实的外部依赖(它的值来自系统时钟)。把它作为构造参数,是把隐藏的依赖显式化——这与"注入数据库连接"是同一件事,不是为了让测试能 mock,而是为了让依赖关系诚实。

顺带一个判断:如果某个依赖在真实运行中也不确定(时间、随机、环境),注入它就是正确的设计;如果它确定且唯一(纯函数、常量),注入它才是过度设计。

代码阅读题(第 4、5 题)

第 4 题的问题:

LocalDate.now() 藏在 finalPrice 内部,于是"周日不减免"这条规则只在周日能被测到。

具体后果:

想测的场景需要什么
非周日 + SAVE10 → 减 1000只能在周一至周六跑测试
周日 + SAVE10 → 不减只能在周日跑测试

如果开发者周三写了两个测试,其中"周日"那个会一直失败(或者被写成错误的期望值而侥幸通过)。

第 5 题:改造方式:

java
public class PriceCalculator {
    private final Clock clock;

    public PriceCalculator(Clock clock) {
        this.clock = clock;
    }

    public long finalPrice(long original, String couponCode) {
        long price = original;
        if ("SAVE10".equals(couponCode) && LocalDate.now(clock).getDayOfWeek() != DayOfWeek.SUNDAY) {
            price -= 1_000;
        }
        return price;
    }
}

改造后能多测出的场景:

场景改造前改造后
非周日 + 有效券 → 减 1000周三能测✅ 随时能测
周日 + 有效券 → 不减只有周日能测✅ 随时能测
非周日 + 无效券 → 不减周三能测✅
券编码大小写不匹配("save10")能测✅

注意改造的成本:一个构造参数 + 一行 LocalDate.now() 的参数。收益是"周日这个分支从'看运气'变成'随时可测'"。

编程题(第 6 题)

改造后:

java
package cn.kb.learn.testing;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@DisplayName("短信发送:可测试性改造")
class SmsSenderTest {
    /** 把静态工厂替换成可注入的接口 */
    interface SmsGateway {
        boolean post(String url, String payload);
    }

    static class SmsSender {
        private final SmsGateway gateway;

        SmsSender(SmsGateway gateway) {
            this.gateway = gateway;
        }

        boolean send(String phone, String content) {
            if (content.length() > 70) {
                throw new IllegalArgumentException("短信内容不能超过 70 字");
            }
            return gateway.post("https://sms.example.com/send", phone + ":" + content);
        }
    }

    @Test
    @DisplayName("内容超长:抛异常,且不需要任何 HTTP 替身")
    void rejects_long_content() {
        // Arrange:这里传一个"一定会让测试失败"的替身,
        // 因为超长校验发生在调用网关之前——如果它被调用了,测试会红
        SmsSender sender = new SmsSender((url, payload) -> {
            throw new AssertionError("网关不该被调用");
        });

        // Act & Assert
        assertThatThrownBy(() -> sender.send("13800000000", "x".repeat(71)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("短信内容不能超过 70 字");
    }

    @Test
    @DisplayName("发送成功返回 true")
    void returns_true_when_gateway_accepts() {
        SmsGateway gateway = mock(SmsGateway.class);
        when(gateway.post("https://sms.example.com/send", "13800000000:你好")).thenReturn(true);
        SmsSender sender = new SmsSender(gateway);

        assertThat(sender.send("13800000000", "你好")).isTrue();
    }

    @Test
    @DisplayName("网关返回失败时返回 false")
    void returns_false_when_gateway_rejects() {
        SmsSender sender = new SmsSender((url, payload) -> false);

        assertThat(sender.send("13800000000", "你好")).isFalse();
    }
}

为什么"内容超长"不需要 HTTP 替身:

因为校验发生在调用网关之前——这个测试根本不关心网关是什么。用一个"被调用就失败"的替身,反而能额外验证一件事:超长时不会发起请求。

这条经验可以推广:

把校验、参数检查尽量放在方法的前面。这样测试这些规则时,完全不需要准备任何外部依赖的替身——测试变短、变快、变清晰。

这叫"早失败"(fail fast),它的收益不只是"更快发现错误",还包括"这部分逻辑变得极易测试"。

综合任务(第 7 题)

依赖设计:

java
class OrderExpiryService {
    private final OrderRepository orderRepository;   // 外部存储:读待处理订单、写取消状态
    private final Notifier notifier;                 // 外部系统:发通知
    private final Clock clock;                       // 不确定来源:当前时间

    // 构造器注入:三者都是"外部世界",无法靠自身推导出来
}
依赖为什么必须注入不注入的后果
OrderRepository数据库是外部系统测试要真连库,慢且脆
Notifier短信/邮件无法在单元测试里观察只能靠打印或真发
Clock时间不可控只能测到"当前时刻"对应的场景;无法测 29/30/31 分钟

测试场景清单(8 个,含 3 个边界):

#场景时间设置期望
1下单 10 分钟创建 +10min不取消
2下单 29 分 59 秒+29:59不取消
3下单整 30 分钟(边界)+30:00取消并通知
4下单 31 分钟+31min取消并通知
5已支付的订单超过 30 分钟+31min,状态 PAID不取消(不该动已支付的)
6静默期 22:00 到期+30min 使其落在 22:00不通知(但仍标记取消)
7静默期边界 21:59:59落在 21:59:59正常通知
8静默期结束后的补发到期在 22:30,检查在次日 08:00补发通知或按规则处理

两个测试的 AAA 写法:

java
@Test
@DisplayName("整 30 分钟:取消订单并发出通知")
void cancels_and_notifies_at_exactly_thirty_minutes() {
    // Arrange
    FakeOrderRepository repository = new FakeOrderRepository();
    repository.save(new Order("o1", OrderStatus.UNPAID, Instant.parse("2026-09-21T10:00:00Z")));
    Notifier notifier = mock(Notifier.class);
    Clock clock = Clock.fixed(Instant.parse("2026-09-21T10:30:00Z"), ZoneId.of("UTC"));
    OrderExpiryService service = new OrderExpiryService(repository, notifier, clock);

    // Act
    service.cancelExpiredOrders();

    // Assert
    assertThat(repository.statusOf("o1")).isEqualTo(OrderStatus.CANCELLED);
    verify(notifier).send("o1", "订单已超时取消");
}

@Test
@DisplayName("29 分 59 秒:不取消")
void does_not_cancel_before_thirty_minutes() {
    // Arrange
    FakeOrderRepository repository = new FakeOrderRepository();
    repository.save(new Order("o1", OrderStatus.UNPAID, Instant.parse("2026-09-21T10:00:00Z")));
    Notifier notifier = mock(Notifier.class);
    Clock clock = Clock.fixed(Instant.parse("2026-09-21T10:29:59Z"), ZoneId.of("UTC"));
    OrderExpiryService service = new OrderExpiryService(repository, notifier, clock);

    // Act
    service.cancelExpiredOrders();

    // Assert
    assertThat(repository.statusOf("o1")).isEqualTo(OrderStatus.UNPAID);
    verifyNoInteractions(notifier);
}

哪些场景需要 FakeClock 而非固定 Clock:

场景需要的能力说明
单次判断("现在该不该取消")固定时钟就够Clock.fixed 设置"检查时刻"
静默期跨越(22:00 到次日 08:00)需要时间推进要模拟"取消时刻落在静默期内"与"静默期结束后补发",用 Clock.offset 或自写 FakeClock
定时调度(每 5 分钟检查一次)需要时间推进固定时钟下调度逻辑只会跑一次

实现建议:用 Clock.offset(baseClock, duration) 就能做出"可移动时钟":

java
Clock base = Clock.fixed(Instant.parse("2026-09-21T22:00:00Z"), ZoneId.of("UTC"));
Clock shifted = Clock.offset(base, Duration.ofMinutes(30));       // 22:30

最后一点:"静默期不通知"这个需求本身就说明"时间语义"很重要——把它写进测试,等于把这条业务规则固化下来。一年后有人想改这个规则时,测试会告诉他"这里有个约定"。

13. 掌握标准 ​

  • [ ] 能说出可测试性的两个维度,并用它们诊断代码;
  • [ ] 能识别四种"不可测试"的信号并给出改法;
  • [ ] 会用注入 Clock 让时间相关逻辑可测;
  • [ ] 判断得出哪些"为测试的设计"值得、哪些过度;
  • [ ] 能用 AAA 组织测试,做到一个测试一个 Act;
  • [ ] 遇到"测不了"时,第一反应是看设计而不是想办法绕。

14. 扩展资源 ​

15. 下一步学习 ​

《FIRST 原则与好测试的标准》:用五条原则给测试做体检。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引