Codepole tech talk: Spock, Instancio, Testcontainers

Codepole tech talk about Spock, Instancio and Testcontainers

Sebastian Błaszczak
Author:
Sebastian Błaszczak

Spock, Instancio and Testcontainers - 3 powerful testing tools

Today, automatic tests are an indispensable part of every project (as they always should be). Good test cases can increase code quality and creates a feeling, that everything works as it should. They should be made in a fast and reliable manner, it will encourage developers to run them as often as they can. In a properly configured process, tests should run every time when something is changed in code, this will create some sort of guarantee, that we've just created something that actually works. And Last, but not least, test cases give an easy entry point to the code, especially, when You have to refactor it. To make life a bit easier, there have been created tools to speed up creating test cases, and here are some of them.

Spock

Introduction

Spock is a tool for creating verbose and highly readable test cases. It uses JUnit runner, so it's accessible to all sorts of editing, building, or CI tools. The best thing is that Spock can be used for every JVM language. This framework has different terminology from that known in JUnit. Test classes are called Specifications and test methods are called feature methods, so to sum up, Spock introduces: "Specifications that describe features". It also provides an approach to how feature methods should look, using the Given-When-Then pattern:

def "should calculate power of two numbers"() {
    given:
    def base = 2
    def exponent = 3

    when:
    def result = Math.pow(base, exponent)

    then:
    result == 8
}

In the above example, we can spot the so-called blocks of the feature method. Spock provides six blocks that could be used in there: given, when, then, expect, cleanup, and where. Every block is pretty straightforward:

  • given - the block where local setup is done,
  • when - block defining test action,
  • then - here we can check all the conditions from when block,
  • expect - acts like when-then block,
  • cleanup - a place where we can clean all things, that was created in the feature method,
  • where - block for creating data-driven tests

Now, the feature method can be rewritten in another, data-driven way:

def "should calculate power of two numbers using data driven approach"() {
    expect:
    result == Math.pow(base, exponent)

    where:
    base | exponent | result
    2    | 3        | 8
    5    | 2        | 25
    6    | 3        | 216
}

The same test can be written in JUnit, using @ParametrizedTest, but it is far less readable, and the format of the method is not kept by the framework:

@ParameterizedTest
@CsvSource(
        value = {
                "2, 3, 8",
                "5, 2, 25",
                "6, 3, 216"
        })
void shouldCalculateThePowerOfTwoNumbers(Integer base,Integer exponent,Double expected){
        // expect
        double result=Math.pow(base,exponent);

        // then
        assertThat(result).isEqualTo(expected);
        }

Structure and features

Every specification can be divided into a couple of parts. Not all the parts are required, the final shape depends on specific case:

  • field part - place where we can define all the global variables, that can be accessed in every feature method,

  • fixture methods - the part where a specification is set up. Spock has pre-defined methods:

    • setup() - a setup that runs before every feature method - JUnit @Before,
    • setupSpec() - a setup that runs before all feature methods - JUnit @BeforeAll,
    • cleanup() - cleaning that runs after every feature method - JUnit @After,
    • cleanupSpec() - cleaning that runs after all feature methods - JUnit @AfterAll
  • feature methods - the part where we define the test cases, this is a core of specification. Those methods will be used to check the system behavior,

  • helper methods - here we can define all the methods that help feature methods to be more readable. It is also common to extract code that is duplicated across the feature methods.

Framework provides a variety of tools that can be used in mocking and interaction-based testing, without any additional libraries. Here is an example:


@RequiredArgsConstructor
public class Service {

    private final Repository repository;

    public void saveItem(String item) {
        repository.save(item);
    }

}

public class Repository {

    List<String> items = new ArrayList<>();

    public void save(String item) {
        items.add(item);
    }
}

Now we can create a feature method that mocks repository and check how many times the method save is invoked:

def "should repository interact once on save"() {
    given:
    Repository repository = Mock()
    def service = new Service(repository)

    when:
    service.saveItem("item")

    then:
    1 * repository.save("item")
}

We can go even further and creates a variety of use cases:

def "should repository interact exact 3 times"() {
    given:
    Repository repository = Mock()
    def service = new Service(repository)

    when:
    service.saveItem("firstItem")
    service.saveItem("secondItem")
    service.saveItem("thirdItem")

    then:
    3 * repository.save(_ as String)
}

def "should repository interact between 1 and 3 times"() {
    given:
    Repository repository = Mock()
    def service = new Service(repository)

    when:
    service.saveItem("firstItem")
    service.saveItem("secondItem")
    service.saveItem("thirdItem")

    then:
    (1..3) * repository.save(_ as String)
}

def "should repository interact exact 3 times when saving value that ends with Item"() {
    given:
    Repository repository = Mock()
    def service = new Service(repository)

    when:
    service.saveItem("firstItem")
    service.saveItem("secondItem")
    service.saveItem("thirdItem")

    then:
    3 * repository.save({ it.endsWith("Item") })
}

This example shows, how we can handle conditions of invocation. Character _ means any value. Sometimes it is needed to specify of type, then we have to use the keyword as. We can specify an inclusive range of invocation using round brackets. It is also possible to specify custom conditions, and how the input of a method should look like.

Why You should use it

This framework provides all the features that can be found in JUnit, but it delivers in a more accessible and readable way. If You have a lot of data-driven tests, Spock is one of the best choice to make, and it runs straight away in projects that are written in JVM languages. For more details check Spock documentation.

Instancio

Introduction

One of the first things, while creating tests, is preparing data. This part is crucial because, without it, we can't test anything. Unfortunately, this part creates, a lot of boilerplate code. It is common to see a huge amount of helper methods or even classes that are designed only for providing a generated object for test purposes. Consider these two classes:

public record Item(String id,
                   String name,
                   String ean,
                   Double price,
                   String description,
                   ItemType type) {
}
record DeliveryItem(String id,
                    String code,
                    LocalDateTime prepareDate,
                    LocalDateTime deliveryDate,
                    Item item) {
}

Let's create a simple test that only creates DeliveryItem object:

def "should create delivery item"() {
    given:
    def delivery = createDeliveryItem()

    expect:
    delivery.id() != null
    delivery.code() != null
    delivery.prepareDate() != null
    delivery.deliveryDate() != null
    delivery.item() != null
}

This is fine, isn't it? but wait, let's see how method createDeliveryItem looks like:

private def createDeliveryItem() {
    return new DeliveryItem(
            RandomStringUtils.randomAlphabetic(5),
            RandomStringUtils.randomAlphabetic(5),
            LocalDateTime.now(),
            LocalDateTime.now(),
            createItem()
    )
}

private def createItem() {
    return new Item(
            RandomStringUtils.randomAlphabetic(5),
            RandomStringUtils.randomAlphabetic(5),
            RandomStringUtils.randomAlphabetic(5),
            random.nextDouble(),
            RandomStringUtils.randomAlphabetic(5),
            generateType()
    )
}

private def generateType() {
    def typeIndex = random.nextInt(0, ItemType.values().size())
    return List.of(ItemType.values()).get(typeIndex)
}

private final ThreadLocalRandom random = ThreadLocalRandom.current()

In this case, it is required to create three helper methods, that only create one test object. Now, let's imagine that Item have to have another field called category and the field DeliveryItem should have address. This triggers changes in those 2 helper methods and probably will create another one. Instancio library was created, to handle this sort of problem. For instance, let's see how this test will look like when the power of Instancio is used:

def "should create delivery item"() {
    given:
    def delivery = Instancio.create(DeliveryItem.class)

    expect:
    delivery.id() != null
    delivery.code() != null
    delivery.prepareDate() != null
    delivery.deliveryDate() != null
    delivery.item() != null
}

Looks similar, right? The main difference is that the helper methods have gone. The best thing is, if something will change in domain classes, it will not break the test case.

Features

Creating objects ia a core functionality of a library. It uses reflection to instantiate classes, for no argument constructors it uses build-in Java mechanism, for other types of classes it uses other library called Objensis - this library wraps easy use in situations when e.g. class has required arguments. There are a lot of ways to create objects using this library, this section will be provided some features, that might be useful in any project.

The simplest way to build an object is to use create static method. This allows us to create a filled object with randomly generated data. Only thing is to provide a type class as a method parameter and Instancio will do the magic for us:

def item = Instancio.create(Item.class)

Instancio brings a lot of possibilities to manipulate data. One of the methods is set, in the example below, Instancio will set all String fields (also in nested objects) as string-value. The documentation says "The allXxx() methods such as allInts(), are available for all core types.", it has a lot of predefined methods:

def stringValues = Instancio.of(Item.class)
        .set(allStrings(), "string-value")
        .create()

The library provides also the ability to sets only a specific field:

def withField = Instancio.of(Item.class)
        .set(field("description"), "desc")
        .create()

We can define a custom generate algorithm using generate method. For instance a number in the range of 1-50:

def generatedValue = Instancio.of(Item.class)
        .generate(allDoubles(), generator -> generator.doubles().range(1D, 50D))
        .create()

In the library, we can find two similar methods set and supply. The set method allows setting the static value to the object. On the other hand, supply method will create a new instance of value when setting fields. So in the example below, all strings will have the same value same-string, but all the dates will differ from each other:

def setSupply = Instancio.of(DeliveryItem.class)
        .set(allStrings(), "same-string")
        .supply(all(LocalDateTime.class), (Supplier) (() -> LocalDateTime.now()))
        .create()

Instancio gives a powerful tool called model. It is a convenient way of defining objects that have a common core, so that can be defined in one place and re-used for all other objects:

def model = Instancio.of(DeliveryItem.class)
        .set(all(LocalDateTime.class), LocalDateTime.now())
        .set(field("id"), "id")
        .set(field("code"), "code")
        .toModel()

Using the model defined above, Instancio allows the creation two objects with the same delivery fields and different items objects:

def firstDelivery = Instancio.of(model)
        .set(field("item"), Instancio.create(Item.class))
        .create()

def secondDelivery = Instancio.of(model)
        .set(field("item"), Instancio.create(Item.class))
        .create()

Why You should use it

The best code to maintain is the one that not exists. This library helps reduce boilerplate code to an absolute minimum and makes the process of creating test data, fast and simple. If Your project has a lot of useless code, Instancio is one of the quick solutions for it. It also provides a variety of ways for creating an object, with custom modes of generating values. For more details check Instancio documentation.

Testcontainers

Introduction

Have You ever had problems with spinning up the Docker container in Your CI pipeline or ever wonder how could You simplify that process? The answer to Your problem is Testcontainers. This library can encapsulate everything related to the Docker containers in Your test cases! It brings integration tests to another level, from now You don't have to mock services or use in-memory databases (such as H2 - which, in some cases, don't have all the features). It has a lot of use cases such as:

  • Providing real data layer,
  • Allows running acceptance tests involving containerized web browsers,
  • Spinning up cloud environment - LocalStack(AWS), Azurite or GCP emulators,
  • Delivering an easy way to test Dockerized microservices,
  • Anything You can think of - that has a Docker container

Features

Testcontainers have already prepared some pre-defined modules, that You can use out of the box e.g. mongoDB. There are a lot more prepared modules, to check all of them, You can go to Testcontainers website and open Modules section. One note, to use this library make sure, that the Docker service is up and running! Let's now spin some containers:

private NginxContainer nginx = new NginxContainer(DockerImageName.parse("nginx"))

def "should create nginx container"() {
    given:
    def port = nginx.firstMappedPort
    def host = nginx.host
    def getRequest = HttpRequest.newBuilder(URI.create("http://" + host + ":" + port))
            .GET()
            .build()
    def client = HttpClient.newHttpClient()

    when:
    def response = client.send(getRequest, HttpResponse.BodyHandlers.ofString())

    then:
    response.body().contains("Welcome to nginx!")
}

This is all it takes to spin up a Docker container. The test specification above uses a predefined module of Nginx, which simplify the process of setting up a container. Testcontainers provide all kinds of features that Docker has, so if there is a need of creating a custom network or mounting volumes, the library provides all:

new NginxContainer(DockerImageName.parse("nginx"))
        .withNetwork(Network.newNetwork())
        .withNetworkAliases("nginx-network")
        .withClasspathResourceMapping("hello-world.html",
                "/usr/share/nginx/html/hello-world.html",
                BindMode.READ_ONLY)

Using the above container configuration, Testcontainers will create nginx server, with a custom network called nginx-network and mount hello-world.html into a static Nginx repository. It is possible to check all of that:

def "check nginx network"() {
    expect:
    nginx.getNetworkAliases().contains("nginx-network")
}

Nginx container should contain static resource on path /hello-world.html, let's check that also:

def helloWorldFile = "hello-world.html"

def "should create nginx container with mounted volume"() {
    given:
    def port = nginx.firstMappedPort
    def host = nginx.host
    def getRequest = HttpRequest.newBuilder(URI.create("http://" + host + ":" + port + "/" + helloWorldFile))
            .GET()
            .build()
    def client = HttpClient.newHttpClient()

    when:
    def response = client.send(getRequest, HttpResponse.BodyHandlers.ofString())

    then:
    response.body().contains("This content is served using Nginx.")
}

Library provides a possibility to check, what is going on inside the container. This can be done using the container log feature, it could be very helpful when using custom containerized services - like, the ones, that were created as microservice in our project:

def helloWorldFile = "hello-world.html"

def "should check nginx container logs"() {
    given:
    def port = nginx.firstMappedPort
    def host = nginx.host
    def getRequest = HttpRequest.newBuilder(URI.create("http://" + host + ":" + port + "/" + helloWorldFile))
            .GET()
            .build()
    def client = HttpClient.newHttpClient()

    and:
    client.send(getRequest, HttpResponse.BodyHandlers.ofString())

    when:
    def logs = nginx.getLogs()

    then:
    logs.contains("GET /hello-world.html")
}

Why You should use it

This library was created for providing an easy and fast way to integrate with containers in test cases. It works perfectly, with all kinds of data layers (like DB) and external services. The main principle here is to be as close to the production environment as possible. It could detect bugs on the development level. Testcontainers create opportunities to discover new things, and it simplifies the way of experimenting with them. The main problem here is time. It takes a while to spin up a container, and even more when we don't have the downloaded Docker image that we want to use (it has to download first). It could be beneficial to create test cases, that can re-use running containers. Another thing is to pre-download Docker image. Despite that, this is a powerful tool that can rise the quality of Your code. For more details check Testcontainers documentation.

Real world example

Now, let's see how we can combine those 3 tools and use them in some real-world example. Assume that we have a clothing shop and the owners want to create an application for it. One of the features that need to be done is a search engine for products. It should have the ability to quickly find items using a full-text search. The suitable solution here would be Elasticsearch. The client also wants to create some discounts with different politics e.g. using code or item-based discounts. If there are two applicable discounts, the service should take the one with the lowest price. Let's create some code for those requirements:


@Builder
@Document(indexName = "item")
public record Item(@Id String id,
                   String name,
                   String ean,
                   Double price,
                   String description,
                   ItemType type) {
}

public enum ItemType {
    T_SHIRT, SHIRT, TROUSERS, BELT, SOCKS
}

We have just described how an item should look like, now let's see how discounts may be defined:

public interface Discount {

    Double calculateDiscount(List<ItemDto> items, String code);

    default Double noDiscount(List<ItemDto> items) {
        return items.stream()
                .map(ItemDto::price)
                .reduce(Double::sum)
                .orElse(0D);
    }

    default Double percentageMultiplier(Double discount) {
        return Optional.of(discount)
                .filter(value -> value < 100)
                .map(value -> (100 - value) / 100)
                .orElseThrow(IllegalArgumentException::new);
    }

    default Double priceWithDiscount(List<ItemDto> items, Double discount) {
        return noDiscount(items) * percentageMultiplier(discount);
    }
}

@RequiredArgsConstructor
public class CodeDiscount implements Discount {

    private final String discountCode;
    private final Double discount;

    @Override
    public Double calculateDiscount(List<ItemDto> items, String code) {
        return codeMatches(code) ? priceWithDiscount(items, discount) : noDiscount(items);
    }

    private boolean codeMatches(String code) {
        return code.equals(discountCode);
    }
}

@RequiredArgsConstructor
public class SpecialItemTypeDiscount implements Discount {

    private final ItemType type;
    private final Double discount;

    @Override
    public Double calculateDiscount(List<ItemDto> items, String code) {
        return hasType(items, type) ? priceWithDiscount(items, discount) : noDiscount(items);
    }

    private boolean hasType(List<ItemDto> items, ItemType type) {
        return items.stream()
                .map(ItemDto::type)
                .anyMatch(itemType -> itemType == type);
    }

}

Everything is set up, so now we can move to actual testing. Our goal is to create a similar test environment to the production one, so we won't be satisfied using some in-memory database for search testing. In this case, Testcontainers becomes handy and on top of that, we don't have to create boilerplate code only for test purposes, so we can use Instancio in that matter:

@Testcontainers
class ElasticContainerSpec extends Specification {

    static protected ElasticsearchContainer elasticsearch

    def setupSpec() {
        elasticsearch = new ElasticsearchContainer(DockerImageName
                .parse("docker.elastic.co/elasticsearch/elasticsearch")
                .withTag("7.17.8"))
                .withExposedPorts(9200)
        elasticsearch.start()
    }
}

@SpringBootTest
class ItemDaoTest extends ElasticContainerSpec {

    @Autowired
    ItemDao itemDao

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry properties) {
        properties.add("elastic.host", elasticsearch::getHttpHostAddress)
    }

    def "should find item by partial name"() {
        given:
        def item = Instancio.create(Item.class)
        def savedItem = itemDao.save(item)

        and:
        def query = substring(item.name())

        when:
        def itemsFound = itemDao.findByQuery(new ItemQuery(query))

        then:
        itemsFound.isPresent()
        itemsFound.get().stream()
                .filter { it -> it.id() == savedItem.id() }
                .allMatch { it -> it == savedItem }
    }
}

In the example above, we can see that the Elasticsearch container configuration can be extracted to another class, so specifications could be even more readable and free of unnecessary distractions. We are using the power of Instancio to create a filled entity and Testcontainers give us the possibility to test against an actual Elasticsearch instance. To see more examples You can check this. Now, we have confirmation that data layers work as it should, let's test discount logic:

@SpringBootTest(classes = PriceCalculator.class)
class PriceCalculatorSpockTest extends Specification {

    @Autowired
    private PriceCalculator calculator

    def "should calculate price based on input"() {
        given:
        def items = [
                ItemDto.builder()
                        .price(price)
                        .type(type)
                        .build()
        ]

        when:
        def resultPrice = calculator.calculate(items, code)

        then:
        resultPrice.isPresent()
        resultPrice.get() == afterDiscount

        where:
        type           | price | code              | afterDiscount
        ItemType.SHIRT | 50D   | "SPECIAL_CODE_15" | 40D
        ItemType.BELT  | 50D   | "WRONG_CODE"      | 45D
        ItemType.SHIRT | 50D   | "WRONG_CODE"      | 50D
        ItemType.SOCKS | 50D   | ""                | 50D
    }
}

To comparison here is the same test case written in JUnit:


@SpringBootTest(classes = PriceCalculator.class)
class PriceCalculatorTest {

    @Autowired
    private PriceCalculator calculator;

    @ParameterizedTest
    @CsvSource(
            value = {
                    "SHIRT, 50, SPECIAL_CODE_15, 40",
                    "BELT, 50, WRONG_CODE, 45",
                    "SHIRT, 50, WRONG_CODE, 50",
                    "SOCKS, 50, {}, 50",
            },
            emptyValue = "{}")
    void shouldCalculatePrice(ItemType type, Double price, String code, Double afterDiscount) {
        // given
        List<ItemDto> items = List.of(ItemDto.builder()
                .price(price)
                .type(type)
                .build());

        // when
        Optional<Double> resultPrice = calculator.calculate(items, code);

        // then
        assertThat(resultPrice).isPresent().contains(afterDiscount);
    }

}

As You can see, the data that is used in this test case is not clear, and adding a new portion of data can be tricky. All parameters are Strings, so we can pass there anything e.g. TSHIRT instead of T_SHIRT. The other problem is the structure of, JUit don't track any blocks, this is only the goodwill of the developer to keep tests clean in the Given-When-Then way.

Conclusion

In this article, we have shown three powerful tools that can leverage code testing to another level. Spock framework that was created to be more verbose and readable, Instancio that can speed up the preparation of tests and Testcontainers which in some integrations cases is irreplaceable. Those libraries can improve readability and make test process a bit less boring. It is also shown that those tools can be used in a real-life project, so why wait and give them a spin in Your next project? All examples and code for the project You can find in this repository.

Happy coding!

Source

Published by
Sebastian Błaszczak
Linkedin
March 2023