Integration test with Testcontainers in Java

Spring Elasticsearch Testcontainer

Often times when it comes to integration testing, the recommendation is to write tests as simple as possible. However, this is not usually the case, especially in an event-driven and microservice architecture. Usually, the service under the test has to connect to some external services to perform some tasks. For instance, the service needs to read and write an Elasticsearch index. And this is where the issue arises.
In this article, we discuss how to easily write integration tests with Testcontainers using Java and Spring Boot.

While there are enough tooling around to emulate external dependency (e.g. Elasticsearch) to carry out the test. They don’t provide the best solution because:

  • The test setup gets extremely complex and messy
  • The test is often unstable
  • Sometimes there is not any library available to mock or fake the external dependency

Elasticsearch is just an example of many. Others could be Kafka, Authentication server, etc.

Just to rehash the first point. It is vital to keep tests as clean and as simple as possible. Test cases should be treated as production code. That is because once a test or its setup gets complex, nobody will be willing to maintain or update it. In the end, developers flag them to skip or remove them.

One obvious solution to overcome the issues highlighted above is to test the code against actual services. But then the cost of setting up the infrastructure and maintenance is often unbearable.

That is where Testcontainers comes handy.

What is Testcontainers?

The idea of Testcontainers is straightforward. It helps to automatically spin up external dependencies as Docker containers within the test setup. Then it runs tests as usual and finally destroys the spun containers. Sounds simple, isn’t it?

With that, we kill two birds with one stone. First, the test setup will be much simpler and cleaner. Second, the tests run against real services and will be close to the production environment.

Now that you know what Testcontainers is, let’s get our hands dirty with it.

An example of Testcontainers

In this section, we go over how to set up a test container with JUnit5. For this purpose, I assume Elasticsearch is an external dependency. So we can compare it with the embedded Allegro library we discussed in this post, here. That helps us to understand the pain of setting up the library better.

The first step of using Testcontainers is to add the core dependency to the project. For a maven project:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.18.3</version>
    <scope>test</scope>
</dependency>

Since we plan to use JUnit5, we also must add the Jupiter extension.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.18.3</version>
    <scope>test</scope>
</dependency>

Lastly, we need to add Elasticsearch dependency of Testcontainers,

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>1.18.3</version>
    <scope>test</scope>
</dependency>

Before jumping to write any tests, we need to do some basic configuration related to container settings such as:

  • Elasticsearch Docker version and tag
  • Elasticsearch cluster name
  • Exposed ports

For simplicity’s sake, we abstract all the configuration in a single class by extending ElasticsearchContainer class as follows:

import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.utility.DockerImageName;

public class BookElasticsearchContainer extends ElasticsearchContainer {

    private static final String ELASTIC_SEARCH_DOCKER = "elasticsearch:8.7.0";
    private static final String CLUSTER_NAME = "cluster.name";
    private static final String ELASTIC_SEARCH = "elasticsearch";
    private static final String DISCOVERY_TYPE = "discovery.type";
    private static final String DISCOVERY_TYPE_SINGLE_NODE = "single-node";
    private static final String XPACK_SECURITY_ENABLED = "xpack.security.enabled";

    public BookElasticsearchContainer() {
        super(DockerImageName.parse(ELASTIC_SEARCH_DOCKER)
            .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"));
        addFixedExposedPort(9200, 9200);
        addEnv(DISCOVERY_TYPE, DISCOVERY_TYPE_SINGLE_NODE);
        addEnv(XPACK_SECURITY_ENABLED, Boolean.FALSE.toString());
        addEnv(CLUSTER_NAME, ELASTIC_SEARCH);
    }
}

Then we can instantiate the above class in the test cases and start and destroy it like this:

import com.madadipouya.elasticsearch.springdata.BookElasticsearchContainer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class DefaultBookServiceIT {
  @Container
  private static final ElasticsearchContainer elasticsearchContainer = new BookElasticsearchContainer();
  
  @BeforeAll
  static void setUp() {
      elasticsearchContainer.start();
  }
  
  @AfterAll
  static void destroy() {
      elasticsearchContainer.stop();
  }
}

Since the test is an integration test, we need to run it with @SpringExtension annotation with @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.<em>RANDOM_PORT</em>). Don’t panic, you will see the complete example later.

Now everything is ready to write the first container-based test. We start with a simple test case in which we create an index in Elasticsearch, then call a service to write a document to Elasticsearch.

We use the Book example we discussed in the Getting started with Spring Data Elasticsearch article. So we create a fresh index and then add a Book to it by calling BookService to interact with Elasticsearch. So our first iteration will look like this:

import com.madadipouya.elasticsearch.springdata.BookElasticsearchContainer;
import com.madadipouya.elasticsearch.springdata.example.model.Book;
import com.madadipouya.elasticsearch.springdata.example.service.BookService;
import com.madadipouya.elasticsearch.springdata.example.service.exception.BookNotFoundException;
import com.madadipouya.elasticsearch.springdata.example.service.exception.DuplicateIsbnException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;

@Testcontainers
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DefaultBookServiceIT {
  @Autowired
  private BookService bookService;

  @Autowired
  private ElasticsearchTemplate template;

  @Container
  private static final ElasticsearchContainer elasticsearchContainer = new BookElasticsearchContainer();

  @BeforeAll
  static void setUp() {
      elasticsearchContainer.start();
  }

  @BeforeEach
  void testIsContainerRunning() {
      assertTrue(elasticsearchContainer.isRunning());
      recreateIndex();
  }

  @Test
  void testCreateBook() throws DuplicateIsbnException {
      Book createdBook = bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
      assertNotNull(createdBook);
      assertNotNull(createdBook.getId());
      assertEquals("12 rules for life", createdBook.getTitle());
      assertEquals("Jordan Peterson", createdBook.getAuthorName());
      assertEquals(2018, createdBook.getPublicationYear());
      assertEquals("978-0345816023", createdBook.getIsbn());
  }
  
  private Book createBook(String title, String authorName, int publicationYear, String isbn) {
        Book book = new Book();
        book.setTitle(title);
        book.setAuthorName(authorName);
        book.setPublicationYear(publicationYear);
        book.setIsbn(isbn);
        return book;
  }

  private void recreateIndex() {
      if (template.indexOps(Book.class).exists()) {
          template.indexOps(Book.class).delete();
          template.indexOps(Book.class).create();
      }
  }
  
  @AfterAll
  static void destroy() {
      elasticsearchContainer.stop();
  }

For creating the index, we rely on @BeforeEach to ensure test cases don’t interfere with each other. Additionally, I do the assertion to ensure the Elasticsearch container is up and running. To create and delete, we rely on ElasticsearchTemplate wire up automatically as long as correct values are set application.properties

As you can see the test is very straightforward. There is almost zero configuration in regard to the Elasticsearch container as if we run the test against an actual Elasticsearch instance in production.

Now that we know how easy it is to write Integration Tests for Elasticsearch using Testcontainers, let’s write some more tests.

package com.madadipouya.elasticsearch.springdata.example.service.impl;

import com.madadipouya.elasticsearch.springdata.BookElasticsearchContainer;
import com.madadipouya.elasticsearch.springdata.example.model.Book;
import com.madadipouya.elasticsearch.springdata.example.service.BookService;
import com.madadipouya.elasticsearch.springdata.example.service.exception.BookNotFoundException;
import com.madadipouya.elasticsearch.springdata.example.service.exception.DuplicateIsbnException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;


@Testcontainers
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DefaultBookServiceIT {

    @Autowired
    private BookService bookService;

    @Autowired
    private ElasticsearchTemplate template;

    @Container
    private static final ElasticsearchContainer elasticsearchContainer = new BookElasticsearchContainer();

    @BeforeAll
    static void setUp() {
        elasticsearchContainer.start();
    }

    @BeforeEach
    void testIsContainerRunning() {
        assertTrue(elasticsearchContainer.isRunning());
        recreateIndex();
    }

    @Test
    void testGetBookByIsbn() throws DuplicateIsbnException {
        bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        Optional<Book> result = bookService.getByIsbn("978-0345816023");
        assertTrue(result.isPresent());
        Book createdBook = result.get();
        assertNotNull(createdBook);
        assertEquals("12 rules for life", createdBook.getTitle());
        assertEquals("Jordan Peterson", createdBook.getAuthorName());
        assertEquals(2018, createdBook.getPublicationYear());
        assertEquals("978-0345816023", createdBook.getIsbn());
    }

    @Test
    void testGetAllBooks() throws DuplicateIsbnException {
        bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        bookService.create(createBook("The Cathedral and the Bazaar", "Eric Raymond", 1999, "9780596106386"));
        List<Book> books = bookService.getAll();

        assertNotNull(books);
        assertEquals(2, books.size());
    }

    @Test
    void testFindByAuthor() throws DuplicateIsbnException {
        bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        bookService.create(createBook("Maps of Meaning", "Jordan Peterson", 1999, "9781280407253"));

        List<Book> books = bookService.findByAuthor("Jordan Peterson");

        assertNotNull(books);
        assertEquals(2, books.size());
    }

    @Test
    void testFindByTitleAndAuthor() throws DuplicateIsbnException {
        bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        bookService.create(createBook("Rules or not rules?", "Jordan Miller", 2010, "978128000000"));
        bookService.create(createBook("Poor economy", "Jordan Miller", 2006, "9781280789000"));
        bookService.create(createBook("The Cathedral and the Bazaar", "Eric Raymond", 1999, "9780596106386"));

        List<Book> books = bookService.findByTitleAndAuthor("rules", "jordan");

        assertNotNull(books);
        assertEquals(2, books.size());
    }

    @Test
    void testCreateBook() throws DuplicateIsbnException {
        Book createdBook = bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        assertNotNull(createdBook);
        assertNotNull(createdBook.getId());
        assertEquals("12 rules for life", createdBook.getTitle());
        assertEquals("Jordan Peterson", createdBook.getAuthorName());
        assertEquals(2018, createdBook.getPublicationYear());
        assertEquals("978-0345816023", createdBook.getIsbn());
    }

    @Test
    void testCreateBookWithDuplicateISBNThrowsException() throws DuplicateIsbnException {
        Book createdBook = bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));
        assertNotNull(createdBook);
        assertThrows(DuplicateIsbnException.class, () -> {
            bookService.create(createBook("Test title", "Test author", 2010, "978-0345816023"));
        });
    }

    @Test
    void testDeleteBookById() throws DuplicateIsbnException {
        Book createdBook = bookService.create(createBook("12 rules for life", "Jordan Peterson", 2018, "978-0345816023"));

        assertNotNull(createdBook);
        assertNotNull(createdBook.getId());

        bookService.deleteById(createdBook.getId());
        List<Book> books = bookService.findByAuthor("Jordan Peterson");

        assertTrue(books.isEmpty());
    }

    @Test
    void testUpdateBook() throws DuplicateIsbnException, BookNotFoundException {
        Book bookToUpdate = bookService.create(createBook("12 rules for life", "Jordan Peterson", 2000, "978-0345816023"));

        assertNotNull(bookToUpdate);
        assertNotNull(bookToUpdate.getId());

        bookToUpdate.setPublicationYear(2018);
        Book updatedBook = bookService.update(bookToUpdate.getId(), bookToUpdate);

        assertNotNull(updatedBook);
        assertNotNull(updatedBook.getId());
        assertEquals("12 rules for life", updatedBook.getTitle());
        assertEquals("Jordan Peterson", updatedBook.getAuthorName());
        assertEquals(2018, updatedBook.getPublicationYear());
        assertEquals("978-0345816023", updatedBook.getIsbn());
    }

    @Test
    void testUpdateBookThrowsExceptionIfCannotFindBook() {
        Book updatedBook = createBook("12 rules for life", "Jordan Peterson", 2000, "978-0345816023");

        assertThrows(BookNotFoundException.class, () -> {
            bookService.update("1A2B3C", updatedBook);
        });
    }

    private Book createBook(String title, String authorName, int publicationYear, String isbn) {
        Book book = new Book();
        book.setTitle(title);
        book.setAuthorName(authorName);
        book.setPublicationYear(publicationYear);
        book.setIsbn(isbn);
        return book;
    }

    private void recreateIndex() {
        if (template.indexOps(Book.class).exists()) {
            template.indexOps(Book.class).delete();
            template.indexOps(Book.class).create();
        }
    }

    @AfterAll
    static void destroy() {
        elasticsearchContainer.stop();
    }
}

Conclusion

In this article, we discussed how to write integration tests with Testcontainers for Elasticsearch in Java. By contrast to the now deprecated Allegro library, sniping up an Elasticsearch instance for testing is simple, efficient, and reliable when utilizing the Testcontainers library. It does not require complex configuration and as a result, tests are cleaner and more maintainable.

The full example source code is available on GitHub at the following link:
https://github.com/kasramp/Spring-Data-ElasticSearch-Example

Inline/featured images credits