An alternative to using Thread.sleep in your Java tests

Marcio Endo
May 23, 2022

edit 2022-05-24: add a timeout section
edit 2023-04-30: add polling section

At times the code you are testing runs in a separate thread than the thread executing the test itself. In this case, knowing when to perform the assertions can be troublesome as you must exercise some sort of synchronization between the threads.

A solution is to use Thread.sleep. While I personally do not not consider it wrong, using Thread.sleep has some drawbacks:

In this blog post I will show you an alternative to using Thread.sleep in your Java tests. This post assumes you can refactor the code you are testing.

The problem

To illustrate the problem let's jump right into our running example:

class AsyncCounter implements Runnable {
  static final int COUNT_TO = 1_000_000;

  private volatile int value;

  public int get() {
    return value;
  }

  @Override
  public void run() {
    while (value < COUNT_TO) {
      value++;
    }
  }

  public void startCounting() {
    var t = new Thread(this);

    t.start();
  }
}

It is a counter or incrementer that runs in a separate thread. You use it by invoking the startCounting() method; and you can query the current value by invoking the get() method. I know... it is of no practical use and has some warning signs, such as updating a volatile in a loop, but it should be enough as our example.

Let's write a test for it. The counter is fixed to count to one million. So, after it has finished its work, we expect the value to be one million.

Of course we could just invoke the run() method straight from our test. But bear with me, we want the counting to occur asynchronously.

An initial solution attempt could be:

public class AsyncCounterTest {
  @Test
  public void test() {
    var counter = new AsyncCounter();

    counter.startCounting();

    assertEquals(counter.get(), AsyncCounter.COUNT_TO);
  }
}

But running this test fails:

FAILED: test
java.lang.AssertionError: expected [1000000] but found [0]

This is expected. Our assertion runs right after the thread starts and before the thread has a chance to do any work.

Using Thread.sleep

A solution could be to add a Thread.sleep after the counting has started and before the assertion, like so:

public class AsyncCounterTest {
  @Test
  public void test() throws InterruptedException {
    var startTime = System.currentTimeMillis();
    var counter = new AsyncCounter();

    counter.startCounting();

    Thread.sleep(1000);

    assertEquals(counter.get(), AsyncCounter.COUNT_TO);

    var totalTime = System.currentTimeMillis() - startTime;
    System.out.println("totalTime=" + totalTime);
  }
}

Apart from the Thread.sleep, I have also modified the test so it prints the total time (in ms) it took running the test. Running this version of the test in my machine gives:

totalTime=1003
PASSED: test

The question is: how much of the time sleeping is actual waiting for the counting to be over and how much is just sleeping and not doing much?

If we reduce the sleeping time from 1s to, say, 5ms and we ask TestNG to run the test three times the test fails (on my machine):

FAILED: test
java.lang.AssertionError: expected [1000000] but found [478484]

FAILED: test
java.lang.AssertionError: expected [1000000] but found [647833]

FAILED: test
java.lang.AssertionError: expected [1000000] but found [655242]

Finding an optimal waiting time is troublesome. Let's analyze some alternatives.

Alternative: use wait() and notify()

The first thing we need to do is refactor our AsyncCounter so it can send a signal when the counting is over. Let's create a listener interface with a single countingOver() method:

interface Listener {
  void countingOver();
}

And we invoke this method at the end of the run() execution: when the counting is over. The full modified code is listed below:

class AsyncCounter implements Runnable {
  static final int COUNT_TO = 1_000_000;

  private final Listener listener;

  private volatile int value;

  public AsyncCounter(Listener listener) {
    this.listener = listener;
  }

  public int get() {
    return value;
  }

  @Override
  public void run() {
    while (value < COUNT_TO) {
      value++;
    }

    listener.countingOver();
  }

  public void startCounting() {
    var t = new Thread(this);

    t.start();
  }

  interface Listener {
    void countingOver();
  }
}

Now we have to refactor our test as well.

After we start the counter we need to block the thread running the test until the counting is over. Remember, the counting happens in a different thread.

In Java we can do this by invoking the wait() method on a object that will be used for synchronization between threads. But before a thread can invoke wait() on an object, it must first acquire the lock on that object. We will use the test object itself for synchronization, like so:

counter.startCounting();

synchronized (this) {
  wait();
}

So the thread running the test:

Before it returns from the wait() method, in other words, inside the wait() method, the thread running the test:

(to be exact the blocked thread can also stop waiting when interrupted. In this case the wait() throws an InterruptedException)

Next we have to ask the counting thread to notify the blocked thread when the counting is over. In Java we can do this by invoking the notify() (or notifyAll()) method on the object being used for synchronization. We are using the test object itself in our case.

So let's have our test class implement the Listener interface and let's code the countingOver() method:

@Override
public void countingOver() {
  synchronized (this) {
    notify();
  }
}

Remember, this method is invoked by the counting thread when the counting is over.

Just like the wait() method, before a thread can invoke the notify() method on an object, it must first acquire the lock on that object.

So the counting thread:

Notice that, since we know there is a single thread waiting, we invoke the notify() method and not the notifyAll() method.

Since now:

The testing thread will try to acquire the lock on the test object again. Once the lock is acquired, it will continue executing by exiting the wait() method.

The full version of the test is listed below:

public class AsyncCounterTest implements AsyncCounter.Listener {
  @Override
  public final void countingOver() {
    synchronized (this) {
      notify();
    }
  }

  @Test
  public void test() throws InterruptedException {
    var startTime = System.currentTimeMillis();
    var counter = new AsyncCounter(this);

    counter.startCounting();

    synchronized (this) {
      wait();
    }

    assertEquals(counter.get(), AsyncCounter.COUNT_TO);

    var totalTime = System.currentTimeMillis() - startTime;
    System.out.println("totalTime=" + totalTime);
  }
}

Running this test results:

totalTime=13
PASSED: test

Variation: use a Semaphore

I personally think the previous solution is enough. In particular because it is running in a testing environment.

Having said that, explicitly using both synchronized and Object.wait() has implications regarding virtual threads. Both will block the carrier thread of the virtual. But scheduler's behavior for each case is slightly different:

You can read more on the JEP 425 page.

The previous version is not wrong. We are not using virtual threads. Still, I think it would be nice to make the example more "loom-friendly". Or "forward compatible".

So let's write a variation of the alternative using a Semaphore instead. JEP 425 gives recommendations on how to mitigate the synchronized issue. It does not mention the Semaphore class explicitly. I am assuming the recommendations can be extended to it. I must stress though: this is an assumption.

First we need to get a Semaphore instance:

private final Semaphore semaphore = new Semaphore(0);

We initialized the semaphore to zero as we want the semaphore to block in the first acquire() invocation. So, right after the counting starts, we call acquire() on the semaphore:

counter.startCounting();

semaphore.acquire();

Once the counting is over, the counting thread must signal the waiting thread. It does so by calling release() on the semaphore:

@Override
public void countingOver() {
  semaphore.release();
}

The full test is listed below:

public class AsyncCounterTest implements AsyncCounter.Listener {
  private final Semaphore semaphore = new Semaphore(0);

  @Override
  public void countingOver() {
    semaphore.release();
  }

  @Test
  public void test() throws InterruptedException {
    var startTime = System.currentTimeMillis();
    var counter = new AsyncCounter(this);

    counter.startCounting();

    semaphore.acquire();

    assertEquals(counter.get(), AsyncCounter.COUNT_TO);

    var totalTime = System.currentTimeMillis() - startTime;
    System.out.println("totalTime=" + totalTime);
  }
}

Running it results in:

totalTime=12
PASSED: test

This variation is less verbose than the previous one.

It requires a new object instance and understanding of the Semaphore class though. In particular why it must be initialized with a zero value in this case.

Additionally, I must add that, apart from this blog post, I have never used a Semaphore before. I suspect this is not quite the use-case the designers of the class had in mind when creating it.

Test time-out

In both alternatives the testing thread will block until signaled by the counting thread. If for any reason the counting thread fails to signal then your test will block indefinitely.

Therefore I highly recommend that you add a time-out to your test. In other words, you should make sure your test fails if it does not complete after a specific amount of time.

How much time? Ironically, we got ourselves back to our initial problem, didn't we? Meaning that, if we decide on a high time-out value and in the case of a wrong implementation, our test suite will spend a lot of time doing nothing. Use a low time-out value and the test will fail before it can complete its work.

But, unlike the Thread.sleep situation, this will only happen when our implementation is wrong.

I am using TestNG. It provides a timeOut in its Test annotation. If the test does not complete in the specified value in milliseconds then TestNG fails the test.

@Test(timeOut = 100)
public void test() {
  // our test
}

I used 100 milliseconds. It is above the 12 milliseconds average I was getting running the tests.

Alternative: polling

I personally like the wait()/notify() solution. But I understand it might not suit everyone.

So you can use polling instead. In other words, you can regularly check if the counter is done counting. When you are sure it is done, you check for the result.

There is a library that can do that for you called Awaitility. My friend Benjamin Marwell has written a blog post featuring it.

Conclusion

In this blog post I presented two variations of an alternative to using Thread.sleep in your tests. It assumes you can refactor the code you are testing so a listener can be introduced.

All solutions (Thread.sleep included) have pros and cons. Which solution you choose depend on your requirements, personal preferences or comfort using one API or the other.

You can find the source code for all of the example in this GitHub repository.