📜 ⬆️ ⬇️

Java time machine

In the world there are many cool little libraries that are not famous, but very useful. The idea is to slowly acquaint Habr with such things under the #javalifehacker tag. Today we will talk about time-test , in which there are only 16 commits, but they are enough. The author of the library is Nikita Koval , and this is a translation of his article, originally written for the blog Devexperts .


It is not easy to write unit tests for time-bound functionality. Sometimes you can take a time-returning method and replace its implementation with a test code. But for testing real applications this is not enough. Let's see why this solution may not work and what is really needed to test the time.




Here is the simplest method that counts the number of days until the end of the world:


fun daysBeforeDoom() { return doomTime - System.currentTimeMillis()) / millisInDay } 

Most likely, to test it, it is enough to simply substitute all System.currentTimeMillis() calls using existing tools ( one , two ) or writing code transformations on ASM or AspectJ (if you need some kind of specialized behavior).


But there are cases when this approach is not enough. Imagine that we are writing an alarm clock that wakes up every day and displays the message: “There are <N> days left until the end of the world”:


 while (true) { Thread.sleep(ONE_DAY) println("${daysBeforeDoom()} days left till the doomsday") } 

But how to test this code? How to check that it is really executed every day and displays the correct message? Using the simplest approach from the example above and replacing System.currentTimeMillis() , you can check the correctness of the message and only. But to test the correctness of the schedule, you will have to wait a whole day.


Thus, it is almost impossible to test such code without using additional tools. So let's write them!


So, there are two methods that return time: System.currentTimeMillis() and System.nanoTime() . In addition, there are several synchronization methods with the ability to specify the maximum wait time: Thread.sleep(..) , Object.wait(..) and LockSupport.park(..) .


To manage time, I want to do some increaseTime(..) method that changes the virtual time and wakes up the necessary flows.


This can be achieved if all methods that work with time are replaced by test implementations. Let's take a look at how this might work.


Test example:


 increaseTime(ONE_DAY) checkMessage() 

You have probably already noticed that this test creates the potential for racing between checking a message and a print operation that is not performed instantly. Of course, you can try to add a pause.


 increaseTime(ONE_DAY) Thread.sleep(500 /*ms*/) checkMessage() 

In normal life, this test will almost always work, but there are no real guarantees that checkMessage() will not be called before the message is displayed. This can happen as a result of increasing complexity of the test logic, or simply when running code on an overloaded server. There may be a desire to increase the timeout, but this solution will only slow down the tests, and there will still be no guarantee of correctness.


Instead, we need a special method that waits until all awakened threads do their job.


Ideally, I would like to write this test:


 increaseTime(ONE_DAY) waitUntilThreadsAreFrozen(1_000/*ms, timeout*/) checkMessage() 

Thus, we need to support not only the virtualization of time-dependent methods, but also the waitUntilThreadsAreFrozen method, which is not easy at the same time.


Working in Devexperts, Nikita wrote a tool called time-test , which solves this problem. Let's see how it works.


Time-test is written as a Java agent. To use it, add the -javaagent:timetest.jar and put it in the classpath. This tool transforms the bytecode and replaces all methods that work with time with the challenges of their implementations. Writing a good java agent is often not an easy task, so Nikita developed the JAgent framework, which simplifies this matter.


When creating tests, you need to enable TestTimeProvider . It implements all the necessary methods (including System.currentTimeMillis() , Thread.sleep(..) , Object.wait(..) , LockSupport.park(..) , etc., and overrides their normal implementation. In most tests, there is no need for direct time management used in the underlying implementation. Therefore, until you connect TestTimeProvider, the tool continues to use the default implementations of the above methods, wrapping them in your own code. After connecting TestTimeProvider , it becomes possible to use the TestTimeProvider.setTime(..) , TestTimeProvider.increaseTime(..) and TestTimeProvider.waitUntilThreadsAreFrozen(..) .


TimeProvider.java :


 long timeMillis(); long nanoTime(); void sleep(long millis) throws InterruptedException; void sleep(long millis, int nanos) throws InterruptedException; void waitOn(Object monitor, long millis) throws InterruptedException; void waitOn(Object monitor, long millis, int nanos) throws InterruptedException; void notifyAll(Object monitor); void notify(Object monitor); void park(boolean isAbsolute, long time); void unpark(Object thread); 

As it was written above, the main problem with the implementation of TestTimeProvider is simultaneous support for both methods for working with time and waitUntilThreadsAreFrozen(..) . Therefore, for each time change, all the necessary threads are initially marked as working, and only then wake up. At the same time, waitUntilThreadsAreFrozen(..) waits until all threads are in a waiting state so that none of them will be marked as working. Within the framework of this approach, threads wake up, reset their mark, perform the task and return to the waiting state - and only then waitUntilThreadsAreFrozen(..) will understand that they have completed.


What a test looks like using TestTimeProvider :


 @Before public void setup() { // Use TestTimeProvider for this test TestTimeProvider.start(/* initial time could be passed here */); } @After public void reset() { // Reset time provider to default after the test execution TestTimeProvider.reset(); } @Test public void test() { runMyConcurrentApplication(); TestTimeProvider.increaseTime(60_000 /*ms*/); TestTimeProvider.waitUntilThreadsAreFrozen(1_000 /*ms*/); checkMyApplicationState(); } 

There is another difficulty with the virtualization of time. The described approach works well if you need to control time throughout the entire JVM. But you usually want to avoid affecting your testing library (such as JUnit), the garbage collector thread, and other things that are not directly related to the tested code fragment. Therefore, it is imperative to determine whether we are running in the code under test and whether we should, therefore, virtualize time. To do this, time-test must know the input points of the code being tested (usually these are test classes). Then time-test starts tracking new threads launches and marks them as “their own”, which means that time virtualization will be applied to them. However, problems may arise if you use ForkJoinPool , since it does not run from the test code, and time-test cannot understand that it is necessary to virtualize time there as well. To work with structures similar to ForkJoinPool, you need to expand the definition of input points.


I think it has now become clear that testing the functionality that is working over time may not be such an easy task. I hope time-test will make your life easier, the source code can be picked up on GitHub .


about the author


Nikita Koval is a research engineer in the dxLab research group of Devexperts. In addition, he is a student at the Department of Computer Technologies at ITMO, where he also teaches a course on multithreaded programming. Mainly interested in multi-threaded algorithms, program verification and analysis.


Minute advertising. Nikita will come to the JBreak 2018 conference (which will be held in less than two weeks) in the report “Towards a fast multi-threaded hash table” to tell us about practical approaches to building high-performance algorithms using the full power of multi-core architectures. There are discussion zones at the conference, so after the report, it will be possible to meet with Nikita and discuss various issues - not only multi-threaded hash tables, but also virtualization of time described in the article. Tickets can be purchased on the official website .

')

Source: https://habr.com/ru/post/349508/


All Articles