主题
界面
主题
《JUnit 5:测试结构与断言》、《AssertJ 与断言语义化》、《Mockito:Mock、Stub 与 Verify》 教了工具。本节讲一个更靠前的问题:有些代码根本测不了。
"测不了"通常不是测试技巧不够,而是设计把不确定性藏在了内部。本节用 Clock 这个具体例子把它讲透,并给出组织测试步骤的通用结构:AAA。
完成本节后,你应该能够:
Clock 的方式让"时间相关逻辑"变得可测;Given-When-Then 与 AAA 的对应关系;看一个"周末促销"的需求:
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)。
根因不在测试,在设计:可控性被藏进了方法内部。
| 维度 | 含义 | 对应的问题 |
|---|---|---|
| 可控性(Controllability) | 能让被测代码进入任意想测的状态吗? | "怎么造出'周日'这个状态?" |
| 可观测性(Observability) | 能观察到代码做了什么吗? | "怎么知道它发了通知?" |
四个"不可测试"的信号(对应"可控性/可观测性"缺失):
| 信号 | 表现 | 为什么难测 |
|---|---|---|
内部 new 依赖 | new JdbcUserRepository(...) 写在方法里 | 无法替换成替身 |
| 静态方法调用 | LocalDate.now()、IdUtil.next() | 无法注入,行为固定 |
| 隐藏的不确定性 | 时间、随机数、UUID、系统属性 | 每次运行结果都不同 |
| 隐藏的全局状态 | 单例、静态可变字段 | 无法隔离,测试互相影响 |
可观测性的缺失通常是另一种问题:方法没有返回值,也没有副作用可供观察(比如只写日志)。这时要么加返回值,要么把"日志"本身作为一个可注入的依赖。
把"不可控的东西"变成构造参数:
public class OrderReporter {
private final Clock clock;
public OrderReporter(Clock clock) { // 时间从外部注入
this.clock = clock;
}
public String report(long amountInCents) {
LocalDate today = LocalDate.now(clock); // 用注入的时钟
...
}
}测试里就能随便"造时间":
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) | ✗ | 无法在纯单元测试里替换,只能靠容器 |
值得(不确定性的来源):
Clock)、随机(Random 或 RandomGenerator)、UUID 生成;不值得(为了"能 mock"而增加间接层):
// 过度设计:唯一的实现,且没有不确定性
interface UserNameFormatter {
String format(String name);
}判断标准:
这个东西的值/行为在测试里可控吗? 不可控 → 注入;可控(纯函数、固定值)→ 直接调用。
@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)。
两者是同一结构的两种说法:
| AAA | Given-When-Then | 含义 |
|---|---|---|
| Arrange | Given(假设) | 前置状态 |
| Act | When(当……时) | 触发的行为 |
| Assert | Then(那么) | 期望的结果 |
GWT 更适合"读起来像一句话"的命名:
@Test
void given_user_exists_when_renamed_then_notification_sent() { }AAA 更适合"结构化注释"。选一种,团队内统一即可——本项目用 AAA(注释 + 空行)。
// 反例一:多个 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 的行为就依赖于第一次的结果——测试不再是一个独立的场景。
见 5.2 节。
见 5.4 节。
static class BadOrderReporter {
String report(long amountInCents) {
LocalDate today = LocalDate.now(); // 内部取时间:不可控
...
}
}测试它的唯一办法:
@Test
void untestable_version_can_only_assert_generic_properties() {
BadOrderReporter reporter = new BadOrderReporter();
String result = reporter.report(10_000L);
// 无法断言具体是哪个分支:今天是不是周日,决定了这个测试能覆盖到什么
assertThat(result).contains("10000 分");
}这个断言是"通用性质"(两个分支都满足它),它证明不了任何一个分支的逻辑是对的。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/TestabilityDemoTest.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 分");
// 想稳定测到「周日促销」分支?只能改系统时间,或者等到周日。
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=TestabilityDemoTest预期输出:
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.011 s
-- in cn.kb.learn.testing.TestabilityDemoTest两个测试的对比是本节的全部论证:
| 可测试版本 | 不可测试版本 | |
|---|---|---|
| 能测到的分支 | 两个都测到了 | 只能测到"今天对应的那个" |
| 断言强度 | 精确断言完整字符串 | 只能断言"包含金额" |
| 换一天跑 | 结果不变 | 可能变红 |
| 额外成本 | 一个构造参数 | 零 |
注意最后一行:"可测试写法"的代价只有一个构造参数——这不是"为测试而过度设计",而是把原本藏在方法里的依赖显式表达出来。
testable_version_covers_both_branches 的两个 report 调用处分别断点,观察 LocalDate.now(clock) 返回的日期稳定地是 2026-09-20 / 2026-09-21——与"今天几号"无关;LocalDate.now() 处断点,观察它返回的是真实的当前日期——这就是不可控的来源;clockAt("2026-09-20") 的时区改成 ZoneId.of("UTC"),观察结果是否变化(提示:Instant.parse("2026-09-20T02:00:00Z") 在 UTC 下是 09-20 02:00,仍是周日;但把小时数改到 23:00 后,时区就可能让它变成另一天)——这是"时区 bug"的典型来源;Thread.sleep 代替可控时间 @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)) 精确测边界。
Method method = Service.class.getDeclaredMethod("internalLogic");
method.setAccessible(true); // 反射测私有方法问题:私有方法是实现细节(T1-012 的核心观点)。测它等于把测试绑死在实现上。
修复:通过公开方法测它产生的行为;如果公开方法测不到,说明这段逻辑应该被提取成一个独立的类(然后它就有了自己的公开接口)。
interface StringTrimmer { String trim(String input); } // 过度设计判断标准:这个类型有没有"不确定性"或多个实现? 没有就别抽象。测试不是设计的目的,它只是设计质量的体检。
见 5.6 节。修复:拆成多个测试,或者用参数化测试(T1-002)。
// 反例:准备阶段自己算了一遍期望值
long expected = items.stream().mapToLong(Item::price).sum() * (100 - discount) / 100;
assertThat(cart.total()).isEqualTo(expected);问题:期望值不应该由"和被测代码同样的算法"算出来——否则两边一起错时测试还是绿的。
修复:期望值写具体的数字(9_000L),让它是"人算出来的、显式写下的"。
目标:把一个"测不了的类"改造成"可测的"。
步骤:
labs/testing-unit 里写一个 TicketService: String status(String ticketId):如果距创建时间超过 24 小时返回 "EXPIRED",否则返回 "OPEN";LocalDateTime.now()(不可测),然后试着为它写测试——记录你遇到了什么困难;Clock,为它写出 4 个测试:23 小时 59 分(未过期)、24 小时整(边界)、24 小时零 1 分、刚创建;TicketService 加一个规则"周末不计入 24 小时"——用 FakeClock 前进到周末验证(提示:这需要 Clock 而非简单的固定时刻,可以用 Clock.offset);验收方式:能说出第 2 步第一版遇到的困难,以及注入 Clock 后解决了什么。
扩展任务:给 TicketService 加一个"随机分配处理人"的功能(从 3 个人里随机选),写出可测的版本(提示:注入 RandomGenerator 或 Supplier<String>),并说明"为什么随机性必须被注入"。
基础题
Clock 不算"为测试而过度设计"?代码阅读题
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;
}
}编程题
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() 是静态调用(不可替换);综合任务
OrderExpiryService 的依赖与构造方式,说明每一项为什么需要注入;提示 1(方向):第 4 题的关键是 LocalDate.now() 藏在方法里,导致"周日不减免"这个分支只在周日能测到。
提示 2(关键点):第 6 题的"内容超长"校验发生在 HTTP 调用之前,所以它不关心 HTTP 客户端是什么——这正是"早失败"带来的可测性红利。
提示 3(接近答案):第 7 题的"静默期判断"需要把时间推进到 22:00 之后,固定时钟做不到(它只能停在某一刻),需要 Clock.offset 构造的"可移动时钟"。
顺带一个判断:如果某个依赖在真实运行中也不确定(时间、随机、环境),注入它就是正确的设计;如果它确定且唯一(纯函数、常量),注入它才是过度设计。
第 4 题的问题:
LocalDate.now() 藏在 finalPrice 内部,于是"周日不减免"这条规则只在周日能被测到。
具体后果:
| 想测的场景 | 需要什么 |
|---|---|
| 非周日 + SAVE10 → 减 1000 | 只能在周一至周六跑测试 |
| 周日 + SAVE10 → 不减 | 只能在周日跑测试 |
如果开发者周三写了两个测试,其中"周日"那个会一直失败(或者被写成错误的期望值而侥幸通过)。
第 5 题:改造方式:
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() 的参数。收益是"周日这个分支从'看运气'变成'随时可测'"。
改造后:
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),它的收益不只是"更快发现错误",还包括"这部分逻辑变得极易测试"。
依赖设计:
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 写法:
@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) 就能做出"可移动时钟":
Clock base = Clock.fixed(Instant.parse("2026-09-21T22:00:00Z"), ZoneId.of("UTC"));
Clock shifted = Clock.offset(base, Duration.ofMinutes(30)); // 22:30最后一点:"静默期不通知"这个需求本身就说明"时间语义"很重要——把它写进测试,等于把这条业务规则固化下来。一年后有人想改这个规则时,测试会告诉他"这里有个约定"。
Clock 让时间相关逻辑可测;fixed / offset / tick 的用法《FIRST 原则与好测试的标准》:用五条原则给测试做体检。