Have you ever wondering how to make your application travel in time? If you have, let me tell you that’s great because in the following tutorial I’m going to show you in the real examples how to manage time for all of three test layers – unit tests, integration tests and end-to-end testing.
Resources
Overview
Let’s think for a moment how we can test the behaviour of our application in different points of time.
The most obvious way is just to wait for time to pass by. But it’s not optimal solution and I think that I don’t have to explain it to you why.
Another solution is targeted to applications that you don’t have control over. It requires to modify bytecode using AspectJ in the way that all of getting time invocations return custom point in time, but it’s not covered in this tutorial.
The last way is targeted to applications that you have full control over. It’s to replace all of the getting time invocations (like LocalDateTime.now(), new Date() … etc) by invoking a custom DateTime provider. It’s very simple solution but it’s easy to implement and it works perfect.
Sample application
For the tutorial purposes I prepared simple SpringBoot application consists of TimeController exposed on the localhost:8080/time REST endpoint. It returns current time from BigBen bean which is responsible for returning current time in the application.
package com.bettercoding.timetraveling.timetravelingdemo; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.time.ZonedDateTime; @RestController @RequestMapping("/time") @RequiredArgsConstructor public class TimeController { private final BigBen bigBen; @GetMapping("/") public ZonedDateTime getCurrentTime() { return bigBen.getApplicationTime(); } }
package com.bettercoding.timetraveling.timetravelingdemo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.time.ZonedDateTime; @Component @RequiredArgsConstructor public class BigBen { public ZonedDateTime getApplicationTime() { return ZonedDateTime.now(); } }
package com.bettercoding.timetraveling.timetravelingdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class TimeTravelingDemoApplication { public static void main(String[] args) { SpringApplication.run(TimeTravelingDemoApplication.class, args); } }
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.bettercoding.timetraveling</groupId> <artifactId>time-traveling-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>time-traveling-demo</name> <description>Demo project of time traveling Spring Boot application</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
DateTimeProvider implementation
I decided to implement the following DateTImeProvider as a thread-safe Singleton. The easiest way to achieve it it to use enum class. DateTimeProvider consists of the internal clock, and following methods:
- timeNow() – returns current time, based on internal clock
- setTime() – sets the internal clock as fix time or custom clock passed as parameter
- resetTime() – resets the internal clock to the default one.
package com.bettercoding.timetraveling.timetravelingdemo; import java.time.Clock; import java.time.ZonedDateTime; public enum DateTimeProvider { INSTANCE; public static DateTimeProvider getInstance() { return INSTANCE; } private final Clock defaultClock = Clock.systemDefaultZone(); private Clock clock = defaultClock; public ZonedDateTime timeNow() { return ZonedDateTime.now(clock); } public void setTime(ZonedDateTime zonedDateTime) { var customClock = Clock.fixed(zonedDateTime.toInstant(), zonedDateTime.getZone()); this.setTime(customClock); } public void setTime(Clock clock) { this.clock = clock; } public void resetTime() { this.clock = this.defaultClock; } }
What we have to do now is to replace all of getting time invocations(LocalDateTime, new Date(), etc…) by invoking DateTimeProvider.getInstance().timeNow(). So let’s modify BigBen class to use the implemented DateTimeProvider.
package com.bettercoding.timetraveling.timetravelingdemo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.time.ZonedDateTime; @Component @RequiredArgsConstructor public class BigBen { public ZonedDateTime getApplicationTime() { return DateTimeProvider.getInstance().timeNow(); } }
Changing time in Unit Tests
Managing the time in UnitTest is very simple, the only thing you have to do is just to invoke DateTimeProvider.getInstance().setTime() before unit test. It’s very important to resetTime() after execution of unit tests because modified time may affect on the other unit tests. Please remember that you cannot run time traveling tests in parallel, because time set in one thread may be overridden by another thread.
package com.bettercoding.timetraveling.timetravelingdemo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; public class BigBenTimeTravelingUnitTest { @BeforeAll static void setup() { ZonedDateTime customAppTime = ZonedDateTime.parse("1990-01-02T11:00:56+02:00", DateTimeFormatter.ISO_ZONED_DATE_TIME); DateTimeProvider.getInstance().setTime(customAppTime); } @AfterAll static void cleanup() { DateTimeProvider.getInstance().resetTime(); } @Test void timeTravelingTest() { //given ZonedDateTime expectedTime = ZonedDateTime.parse("1990-01-02T11:00:56+02:00", DateTimeFormatter.ISO_ZONED_DATE_TIME); BigBen bigBen = new BigBen(); //when var applicationTime = bigBen.getApplicationTime(); //then Assertions.assertEquals(expectedTime, applicationTime); } }
Managing the time in Integration Tests
Let’s modify BigBen class to use DateTimeProvider bean instead of invoking DateTimeProvider.getInstance().timeNow() directly. This step is not necessary, but I would like to show you, that you can do it too.
package com.bettercoding.timetraveling.timetravelingdemo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.time.ZonedDateTime; @Component @RequiredArgsConstructor public class BigBen { private final DateTimeProvider dateTimeProvider; public ZonedDateTime getApplicationTime() { return dateTimeProvider.timeNow(); } }
Before you you run the application you have to provide DateTimeProvider bean configuration. Please, don’t mark DateTimeProvider as a component. It’s very important to create a custom bean configuration that you return DateTimeProvider.getInstance() instead creating new instance.
package com.bettercoding.timetraveling.timetravelingdemo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TimeConfiguration { @Bean DateTimeProvider dateTimeProvider() { return DateTimeProvider.getInstance(); } }
If you made all of the previous steps you can modify the time in the similar way as you made it in the unit tests case.
package com.bettercoding.timetraveling.timetravelingdemo; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @SpringBootTest class TimeTravelingDemoApplicationTests { @Autowired private BigBen bigBen; @BeforeAll static void setup() { ZonedDateTime customAppTime = ZonedDateTime.parse("1990-01-02T11:00:56+02:00", DateTimeFormatter.ISO_ZONED_DATE_TIME); DateTimeProvider.getInstance().setTime(customAppTime); } @AfterAll static void cleanup() { DateTimeProvider.getInstance().resetTime(); } @Test void contextLoads() { } @Test void timeTravelTest() { //given ZonedDateTime expectedTime = ZonedDateTime.parse("1990-01-02T11:00:56+02:00", DateTimeFormatter.ISO_ZONED_DATE_TIME); //when ZonedDateTime applicationTime = bigBen.getApplicationTime(); //then Assertions.assertEquals(expectedTime, applicationTime); } }
Managing time in End2End testing
End2End testing is usually performed in some test environment that our application exists in and it is managed by some external application (for example TestNg and Selenium based application). Let’s call it End2EndTestPlayer. The application is responsible for playing a test scenario across tested applications.
The easiest way to test our application in such environment is to expose some REST endpoint that you can set and reset previously implemented DateDimeProvider externally. The following TimeManagementController exposes POST method on /time-management/ resource that allows you to set the time in the application and it also exposes DELETE method for resetting the time.
It’s not a good idea to always expose the TimeManagementController (for example in production environment) so you can add @ConditionalOnPropoerty annotation and use time-management.enable property to manage environments where the changing time feature should be enabled.
package com.bettercoding.timetraveling.timetravelingdemo; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.web.bind.annotation.*; import java.time.ZonedDateTime; @RestController @RequestMapping("/time-management") @ConditionalOnProperty(prefix = "time-management", name = "enabled", havingValue = "true") @RequiredArgsConstructor public class TimeManagementController { private final DateTimeProvider dateTimeProvider; @PostMapping("/") public void changeApplicationTime(@RequestBody ChangeApplicationTimeRequest changeApplicationTimeRequest) { this.dateTimeProvider.setTime(changeApplicationTimeRequest.getZonedDateTime()); } @DeleteMapping public void resetApplicationTime() { this.dateTimeProvider.resetTime(); } @Data private static class ChangeApplicationTimeRequest { ZonedDateTime zonedDateTime; } }
time-management: enabled: true
That’s all what I’ve prepared for you in this tutorial, if I helped you, please consider sharing this post to help me gain a wider audience.
Thanks and I hope to see you in my next tutorial.