Spocklight: Testing Asynchronous Code With DataVariable(s)
Testing asynchronous code needs some special treatment. With synchronous code we get results from invoking method directly and in our tests or specifications we can easily assert the value. But when we don’t know when the results will be available after calling a method we need to wait for the results. So in our specification we actually block until the results from asynchronous code are available. One of the options Spock provides us to block our testing code and wait for the code to be finished is using the classes DataVariable
and DataVariables
. When we create a variable of type DataVariable
we can set
and get
one value result. The get
method will block until the value is available and we can write assertions on the value as we now know it is available. The set
method is used to assign a value to the BlockingVariable
, for example we can do this in a callback when the asynchronous method support a callback parameter.
The BlockingVariable
can only hold one value, with the other class BlockingVariables
we can store multiple values. The class acts like a Map
where we create a key with a value for storing the results from asynchronous calls. Each call to get the value for a given key will block until the result is available and ready to assert.
The following example code is a Java class with two methods, findTemperature
and findTemperatures
, that make asynchronous calls. The implementation of the methods use a so-called callback parameter that is used to set the results from invoking a service to get the temperature for a city:
package mrhaki;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.function.Consumer;
class Async {
private final ExecutorService executorService;
private final WeatherService weatherService;
Async(ExecutorService executorService, WeatherService weatherService) {
this.executorService = executorService;
this.weatherService = weatherService;
}
// Find temperature for a city using WeatherService and
// assign result using callback argument.
void findTemperature(String city, Consumer<Result> callback) {
// Here we do an asynchronous call using ExecutorService and
// a Runnable lambda.
// We're assigning the result of calling WeatherService using
// the callback argument.
executorService.submit(() -> {
int temperature = weatherService.getTemperature(city);
var result = new Result(city, temperature);
callback.accept(result);
});
}
// Find temperature for multiple cities using WeatherService and
// assign result using callback argument.
void findTemperatures(List<String> cities, Consumer<Result> callback) {
cities.forEach(city -> findTemperature(city, callback));
}
record Result(String city, int temperature) {
}
interface WeatherService {
int getTemperature(String city);
}
}
To test our Java class we write the following specification where we use both DataVariable
and DataVariables
to wait for the asynchronous methods to be finished and we can assert on the resulting values:
package mrhaki
import spock.lang.Specification
import spock.lang.Subject
import spock.util.concurrent.BlockingVariable
import spock.util.concurrent.BlockingVariables
import java.util.concurrent.Executors
class AsyncSpec extends Specification {
// Provide a stub for the WeatherService interface.
// Return 21 on the first call and 18 on subsequent calls.
private Async.WeatherService weatherService = Stub() {
getTemperature(_ as String) >>> [21, 18]
}
// We want to test the class Async
@Subject
private Async async = new Async(Executors.newFixedThreadPool(2), weatherService)
void "findTemperature should return expected temperature"() {
given:
// We define a BlockingVariable to store the result in the callback,
// so we can wait for the value in the then: block and
// asssert the value when it becomes available.
def result = new BlockingVariable<Async.Result>()
when:
// We invoke the async method and in the callback use
// our BlockingVariable to set the result.
async.findTemperature("Tilburg") { Async.Result temp ->
// Set the result to the BlockingVariable.
result.set(temp)
}
then:
// Now we wait until the result is available with the
// blocking call get().
// Default waiting time is 1 second. We can change that
// by providing the number of seconds as argument
// to the BlockingVariable constructor.
// E.g. new BlockingVariable<Long>(3) to wait for 3 seconds.
result.get() == new Async.Result("Tilburg", 21)
}
void "findTemperatures should return expected temperatures"() {
given:
// With type BlockingVariables we can wait for multiple values.
// Each value must be assigned to a unique key.
def result = new BlockingVariables(5)
when:
async.findTemperatures(["Tilburg", "Amsterdam"]) { Async.Result temp ->
// Set the result with a key to the BlockingVariables result variable.
// We can story multiple results in one BlockingVariables.
result[temp.city()] = temp.temperature()
}
then:
// We wait for the results key by key.
// We cannot rely that the result are available in the
// same order as the passed input arguments Tilburg and Amsterdam
// as the call will be asynchronous.
// But using BlockingVariables we dont' have to care,
// we simply request the value for a key and the code will
// block until it is available.
result["Amsterdam"] == 18
result["Tilburg"] == 21
}
}
Written with Spock 2.3-groovy-4.0.