使用java.time API提高可测试性的教程

295 阅读8分钟

使用java.time API以获得更好的可测试性

日期/时间逻辑有很多边缘情况。在这里,我们将研究如何让时钟听从你的意愿,以获得更好的可测试性。

代码中的日期/时间逻辑是现实世界的混乱颠覆了数字领域相对简单的规则的地方。把这种令人困惑的边缘案例的大杂烩归咎于天体的运动和教皇格雷戈里十三世(格里高利历),但你必须处理好它。我相信你们都知道12月31日可以在第52周或第53周,而1月1日可以在第0周或第1周,而且你们对不同国家的这些规则了如指掌(哦,对!)。我也相信你能用手工编码的逻辑来计算跨越多个时区的两个表示法之间的秒差,以及夏令时的跳跃。

不要等待下一个闰年的到来

请原谅我的讽刺。与时间有关的边缘案例应该被测试,特别是如果你的代码有自制的日期/时间逻辑(你应该尽量减少)。应用程序代码处理的时间表示,要么作为java.time类之一传递,要么作为一些序列化格式的字符串,例如2022-12-03。对于这一类,你要编出足够突出的边缘,并确保它们在单元测试中被覆盖。但往往代码需要知道当前的时间是什么。这个新的、不可预测的时间戳可能只是最终出现在日志语句中,但它也可能是更多关键业务逻辑的输入。每当你引用任何*.now() 方法时,你的程序就引入了一个副作用,变得不那么确定。

传统单元测试的问题是显而易见的:我们不能等到下一个闰日来确保代码能很好地处理2月29日。 所以,我们通常不对它进行测试,希望一切顺利。我曾经目睹过一次莫名其妙的崩溃,让整个团队都感到困惑。第二天,即3月1日,它就消失了。结果正如我们所料:一个幼稚的、自创的日期处理算法,每四年就会崩溃一次。

操纵时钟的四种策略

在本教程中,我将提供一些策略,以帮助确保询问当前时间的代码仍然能够被正确地进行单元测试。所有情况下的方法都是拦截或替换默认的API调用,调用一个模拟或测试用的double,返回测试中配置的日期/时间,使我们的生产代码再次成为确定性的。现在,最好的策略是,当你可以把当前时间作为一个参数接收时,不要在方法中查询它。尽量减少代码中需要询问时间的地方。有四种基本策略,测试代码可以操纵时钟:

  • 将对java.time APInow() 方法的调用包装成一个自定义静态类
  • 使用一个可注入的自定义DateTime服务。
  • 注入一个java.time.Clock 的子类。
  • 用一个嘲讽框架拦截对now() 方法的静态调用 。

前三个策略在你能处理的方面很灵活,但需要对生产代码做一些适度的修改。最后一个方案只能处理固定的日期,但让你的生产代码不受影响,这可能是使用它的一个令人信服的理由。

你可以在附带的GitLab项目中找到所有代码样本。

Shell

git clone git@gitlab.com:jsprengers/testing-for-time.git

策略1:一个静态的封装器

让我们看看最简单的变体:一个静态类,它封装了 LocalDateTime.now() 的调用。

public static LocalDateTime currentDateTime() {
   return LocalDateTime.now();

}

在你的生产代码中,你按以下方式使用它:

Java

public ExecutionResult runBatchWithWrapper() {
     LocalDateTime started = DateTimeWrapper.currentDateTime();
     simulateLengthyOperation();
     LocalDateTime finished = DateTimeWrapper.currentDateTime();
     return new ExecutionResult(started, finished);
 }

假设我们希望它总是返回一个固定的瞬间。在我们的测试中,我们可以按如下方式配置:

Java

public class DateTimeWrapper {
 
     private static LocalDateTime instant; 
     public static void setFixed(Instant instant) {
         instant = LocalDateTime.ofInstant(instant, ZoneId.of("UTC"));
     }
 
     public static LocalDateTime currentDateTime() {
         if (instant != null) {
             return instant;
         } else {
             return LocalDateTime.now();
         }
     }
 }

在你的测试中调用setFixed(..) ,无论何时查询当前日期/时间,你都会得到相同的值。你已经有效地停止了时钟。然而,这可能并不总是你想要的。我们的示例代码记录了两次调用当前时间的差异,这些差异不应该是相同的。我们想为我们的测试将时钟向前移动,但不要让它停止计时。简单地说,我们将其配置为在当前时间上增加一个给定的持续时间。每个选项都会使其他选项无效。这里不支持过去的偏移量,但我相信你可以自己实现。

public class DateTimeWrapper {
 
     private static LocalDateTime instant;
     private static Duration offset;
 
     public static void setFixed(Instant instant) {
         DateTimeWrapper.instant = LocalDateTime.ofInstant(instant, ZoneId.of("UTC"));
         offset = null;
     }
 
     public static void setOffset(Duration duration) {
         offset = duration;
         instant = null;
     }
 
     public static LocalDateTime currentDateTime() {
         if (instant != null) {
             return instant;
         } else if (offset != null) {
             return LocalDateTime.now().plus(offset);
         } else {
             return LocalDateTime.now();
         }
     }
 }

我们可以按以下方式运行测试:

Java

@Test
 void runCustomDateTimeServiceWithFixedTime() {
     DateTimeWrapper.setFixed(fixedInstant);
     var result = service.runBatchWithWrapper();
     //assertions 
  }

策略2:注入一个自定义可变的DateTimeService

不过,这种方法感觉有些不对劲。生产代码可以访问这些setter方法,这不是很好。不在非测试代码中引入测试工具是有道理的。一个更好、更复杂的策略是使用依赖性注入,在一个可变的测试版本旁边有一个生产版本。这看起来如下。

我们定义一个简单的DateTimeService接口

public interface DateTimeService {
     LocalDateTime currentLocalDateTime();
 }

和下面的生产实现:

Java

@Profile("!test")
@Service
public class DateTimeServiceImpl implements DateTimeService{
     public LocalDateTime currentLocalDateTime(){
         return LocalDateTime.now();
     }
}

还有一个用于测试的可变版本:

Java

//The test implementation contains the same logic as the static wrapper
@Profile("test")
@Service
public class MutableDateTimeService implements DateTimeService {
 
     @Override public LocalDateTime currentLocalDateTime() {
 		[...]
     }
}

而我们的使用方法如下

Java

@Autowired
DateTimeService dateTimeService;
 
public ExecutionResult runBatchWithCustomDateTimeService() {
     LocalDateTime started = dateTimeService.currentLocalDateTime();
     simulateLengthyOperation();
     LocalDateTime finished = dateTimeService.currentLocalDateTime();
     return new ExecutionResult(started, finished);
}

我们有两个DateTimeService 的实现,而且只有一个被实例化了。由于DateTimeServiceIntegrationTest 在 "测试 "配置文件下运行,它选择了MutableDateTimeService 的实例。在生产中,Spring选择了默认的DateTimeServiceImpl

@SpringBootTest()
@Import(MutableDateTimeService.class)
@ActiveProfiles("test")
public class DateTimeServiceIntegrationTest {

}

策略3:注入java.time.Clock实例

一个类似的基于注入的方法,不需要你编写和配置一个模板式的生产实现,而是使用 java.time.Clock 类。java.time API有一个抽象的Clock类,你可以把它作为一个参数添加到所有的now() 方法中。通过选择一个不同的Clock,你可以假装你在北京,或者假装现在永远是2016年2月29日。在生产中,只需要注入一个Clock实例,并按如下方式使用它。

Java

@Autowired
Clock clock;

public ExecutionResult runBatchWithClock() {
   LocalDateTime started = LocalDateTime.now(clock);
}

除非另有配置,Spring会选择java.time.Clock.SystemClock 。如果在你的测试中,你想使用一个固定的日期或一个偏移量,你可以在Spring配置类中分别使用Clock.fixed(..)Clock.offset(..)

Java

@Configuration
public class TestConfig {
@Bean
Clock fixedClock(){
   return Clock.fixed(Instant.from(someDateTime), ZoneId.systemDefault());}
}

对于测试来说,如果你想动态地操作你的偏移量或固定时间,标准的实现就会有缺陷,因为Clock 对象中的固定时刻或偏移量在构建后是不可改变的。一个可能的用例是模拟每天的批处理程序的30次运行,每次将时钟提前24小时。 但是Clock 并不是最终的,所以我们可以制作我们自己的MutableClock 版本。它与其他所有的类非常相似。在gitlab项目中看一看吧。

策略4:用Mockito拦截静态调用

最后一种嘲弄方法是完全不同的野兽。它不需要依赖性注入,让你的生产代码完全不受影响。它归结为拦截对时间API的静态方法调用,并在Mockito嘲弄框架的帮助下配置响应。嘲弄静态方法不再是以前的麻烦事了。从3.4.0开始,它是Mockito的标准配置,但你需要添加额外的Mockito-inline依赖项(见项目中的pom.xml )。

@Test
void runMockedDateTimeWithFixedTime() {
    try (MockedStatic<LocalDateTime> mockedStatic = Mockito.mockStatic(LocalDateTime.class)) {
        mockedStatic.when(() -> LocalDateTime.now(ArgumentMatchers.any(Clock.class))).thenReturn(fixedLocalDateTime);
        var result = service.runBatchWithClock();
        assertThat(result.started()).isEqualTo(result.finished());
    }
    //notice that the static mocking is only in effect within the above try block, which is of course how we would want it.
    assertThat(LocalDateTime.now().getYear()).isGreaterThanOrEqualTo(2022);
}

最后的建议:不要自己动手

我希望你觉得这个教程很有用。最后,让我根据我对一些没有经验的开发者毫无头绪的DIY日期处理的经验,给你一个最后的建议。不要重新发明轮子的最有说服力的理由,也是你不应该推出自己的密码学解决方案的理由。首先,这比你想象的要难。你能重新发明的最好的轮子仍将对意外边缘情况的颠簸道路毫无准备。但更重要的是,这是一个非常普遍的需求,所以通用的解决方案已经处理了几乎所有你可能需要的东西。这也适用于我在本教程中介绍的任何测试工具。保持简单。