Using Testcontainers in a Spring Boot application with MariaDB

Posted on Nov 13, 2022

I created a follow-up for Spring Boot 3.1. Check it here

Even when things changed, the concept of Testcontainers is the same and you can use it also in the way it is described here.

When writing integration tests for a service one of the problems which need to be attacked is the database: You want to assure that an entry is really stored in the database.

For that there are multiple ways, like using a test instance of a database, using a deployed database and use a prefix for all data for later deletion, a local database or an in-memory database like H2.

The first ways lead to some issues and who wants to pollute the production database with generated testing data? In my opinion that should not be the way to do it. However H2 seems to be a good way: An in-memory database which can be deleted right afterwards. But if you use different SQL flavors you’ll notice some problems as they have different dialects.

Testcontainers seem to solve this problem: While running tests you start a temporary container which serves a testing database.

And you can use it for different databases and not only databases.

Let’s have a look at a simple example to see how it works.

Example application

For the example, let’s setup a Spring Boot application with the following dependencies:

dependencies {
	compileOnly "org.projectlombok:lombok"
	annotationProcessor "org.projectlombok:lombok"
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.8'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation "org.testcontainers:testcontainers:1.17.5"
	testImplementation "org.testcontainers:mariadb:1.17.5"
}

The application will have the following structure:

\---src
    +---main
    |   +---java
    |   |   \---de
    |   |       \---dkwr
    |   |           \---testcontainers
    |   |               |   TestcontainersApplication.java
    |   |               |
    |   |               \---book
    |   |                       Book.java
    |   |                       BookController.java
    |   |                       BookRepository.java
    |   |
    |   \---resources
    |       |   application.yml
    |       |
    |       \---sql
    |               V1__init.sql
    |
    \---test
        +---java
        |   \---de
        |       \---dkwr
        |           \---testcontainers
        |               |   AbstractBaseTest.java
        |               |   TestcontainersApplicationTests.java
        |               |
        |               \---book
        |                       BookControllerTest.java
        |
        \---resources
                application.yml

The book entity

An entity is needed to store data. In this example the entity is pretty simple with just two properties:

Book.java:

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bookId;

    @Column
    public String name;

    public Book(String name) {
        this.name = name;
    }
}

This is accordingly the SQL script.

V1__init.sql:

CREATE TABLE book (
  book_id   BIGINT       NOT NULL AUTO_INCREMENT,
  name      VARCHAR(100) NOT NULL,
  PRIMARY KEY (book_id)
);

The repository

The repository to access the database.

BookRepository.java:

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}

The controller

A small controller with two methods to store and retrieve data:

BookController.java:

@Controller
public class BookController {
    @Autowired
    BookRepository bookRepository;

    public void add(Book book) {
        bookRepository.save(book);
    }

    public List<Book> getAll() {
        return bookRepository.findAll();
    }
}

Setting up the tests

Now lets setup the tests. If you have copied the code from above into your application and didn’t change the application.yml, you’ll see that you can’t start it up, because the database connection is missing. You can add one, but you don’t need it in the src directory for this example (because we will just start the tests).

Configuration

I’ll focus on the test directory and add a connection to a MariaDB database started up as a test container.

application.yml:

spring:
  datasource:
    url: jdbc:tc:mariadb://localhost:3306/test-example?TC_INITSCRIPT=file:src/main/resources/sql/V1__init.sql
    username: root
    password: test
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
  jpa:
    hibernate:
      ddl-auto: validate

Two things to keep in mind here:

First, the driver-class-name equals to a Testcontainer driver which you have added as following to your build.gradle: testImplementation "org.testcontainers:mariadb:1.17.5"

If you are not using MariaDB, then check the modules on the Testcontainers page.

Second, the url is quite different to the normal url. As documented on the Testcontainers page, you need to add tc after the jdbc string.

So the normal url would be:

jdbc:mysql://localhost:3306/test-example and you change it to:

jdbc:tc:mariadb://localhost:3306/test-example.

That’s it! There are also some parameters which make the life easier, like init scripts, which I’ve also added here: TC_INITSCRIPT=file:src/main/resources/sql/V1__init.sql

This is the path to the SQL script to setup the table for the book entity.

Test cases

The test cases are written as usual.

First let’s create a base test.

AbstractBaseTest.java:

@SpringBootTest(
        classes = TestcontainersApplication.class // <- This is the main class
)
public abstract class AbstractBaseTest {
}

And create a concrete test. BookControllerTest.java:

public class BookControllerTest extends AbstractBaseTest {
    @Autowired
    BookController bookController;

    @Test
    public void bookExistsInTableTest() {
        Book book = new Book("The book");
        bookController.add(book);

        List<Book> bookList = bookController.getAll();
        Assertions.assertEquals(1, bookList.size());
    }
}

When running the tests it is possible to see the INFO logs from testcontainers and see that the container is being created:

2022-11-13 18:54:35.637  INFO 17440 --- [    Test worker] docker[mariadb:10.3.6]                   : Creating container for image: mariadb:10.3.6
2022-11-13 18:54:35.800  INFO 17440 --- [    Test worker] docker[mariadb:10.3.6]                   : Container mariadb:10.3.6 is starting: 4898a8409586187623a735a4621a33b9a59cc4d9e9c78eefe1482465a43b9268
2022-11-13 18:54:36.294  INFO 17440 --- [    Test worker] docker[mariadb:10.3.6]                   : Waiting for database connection to become available at jdbc:mariadb://localhost:55655/test-example using query 'SELECT 1'
2022-11-13 18:54:43.493  INFO 17440 --- [    Test worker] docker[mariadb:10.3.6]                   : Container is started (JDBC URL: jdbc:mariadb://localhost:55655/test-example)
2022-11-13 18:54:43.495  INFO 17440 --- [    Test worker] docker[mariadb:10.3.6]                   : Container mariadb:10.3.6 started in PT7.8578505S
2022-11-13 18:54:43.509  INFO 17440 --- [    Test worker] org.testcontainers.ext.ScriptUtils       : Executing database script from file:src/main/resources/sql/V1__init.sql
2022-11-13 18:54:43.532  INFO 17440 --- [    Test worker] org.testcontainers.ext.ScriptUtils       : Executed database script from file:src/main/resources/sql/V1__init.sql in 23 ms.

Conclusion and open questions

The first look at Testcontainers is really promising: With a few lines it’s possible to setup a clean database for test cases. The learning curve is very flat, which is good. Usually no one wants to think a lot about test setups and have an easy way for creating them.

Unfortunately I didn’t have the time to take a closer look at this, but in the future I want to answer the following use-cases:

  • Keep the database between tests and don’t delete them (written up here). Because the startup time is high.
  • Use Testcontainers with DBRider and replace H2 in an existing application. That’s really interesting for me, because I would like to know if it can really be integrated so easy into an already existing application. But I think yes, it can!