Integration testing with testcontainers and Liquibase

·

5 min read

Integration testing with testcontainers and Liquibase

Integration tests need good test data

Next to unit tests, integration tests offer an additional level of detecting defects in software. Integration tests can give confidence that separate modules are working together as designed. And when external systems are involved, integration tests can verify that on the interface level, an application integrates with an external system. The latter is usually done by stubbing the external system, e.g. using Wiremock.

In many cases, one of the external interfaces that are used in an application is a database. Many applications require a database for persistent storage of reference and application data. Thus when running an integration test, this test needs a database that holds correct reference and test data. Test data can be hard to maintain. Often a shared database is used that gets polluted quickly after running various tests. Or a personal database is used, which also gets polluted and is hard to set up.

In this article, we’ll discuss how to use a testcontainer, and fill it with relevant test data as part of a test. This implies that the image can be more or less steady, while the database will always have the correct test data.

Version info

We will be using the following technology stack:

  • Testcontainers version 1.17.6, with a PostgreSQL database image, version 9.6.12

  • Docker desktop version 4.16.2

  • Liquibase version 4.19.0

  • Spring boot version 2.7.8

  • Spock version 2.3 for testing (based on Groovy)

A note on Docker desktop. For larger enterprises, the use of Docker desktop (which we need to run a Docker daemon) is not for free. However, free alternatives exist. Specifically, Lima based VMs, such as Colima can be used for MacOS (and Linux).

Creating a database using an image

Creating a database using testcontainers is easy. Using a static initializer, the image can be downloaded and started.

static PostgreSQLContainer postgres

static {
   postgres = (PostgreSQLContainer) new PostgreSQLContainer(PostgreSQLContainer.IMAGE)
           .withDatabaseName("bookstore")
           .withUsername("devgs")
           .withPassword("secret")
           .withExposedPorts(5432)
   postgres.start()
   def jdbcUrl = postgres.jdbcUrl
   LOG.info("Setting db url to '{}'", jdbcUrl)
   System.setProperty("spring.datasource.url", jdbcUrl)
}

Since testcontainers will be running with a mapped port, we need to update Spring’s datasource URL, so that it points towards the PostgreSQL database inside the testcontainer.

And that's it! The database is up and running before the Spring container starts. And Spring knows about it.

Setting up the database using Liquibase

Now that we have a running database, we need to set it up:

  • Most likely a schema is needed

  • And in the schema tables are needed

  • And the tables need references in the form of foreign keys

  • Etc, etc.

Liquibase is an excellent tool to maintain a database scheme over time. It can create and delete database structures, fill tables with data, and many other things. It works with changesets. A changeset is a set of instructions to set up the database. Liquibase maintains a log of which changesets have been applied. When starting the Spring container, the changesets that were not applied are automatically applied. So the database we are using always has the latest database schema installed.

In this example we use PostgreSQL, but all major database vendors are supported. Below is an example changeset.

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
       xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
       xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd
                       http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">

   <!--
       Book core tables
   -->
   <changeSet id="00000000000001" author="devgs" context="prod">
       <sql dbms="postgresql" endDelimiter=";">
           CREATE SCHEMA bookstore
       </sql>
       <createTable tableName="book" schemaName="bookstore">
           <column name="id" type="int" autoIncrement="true">
               <constraints primaryKey="true" nullable="false"/>
           </column>
           <column name="title" type="varchar(50)">
               <constraints nullable="false"/>
           </column>
           <column name="isbn" type="varchar(13)">
               <constraints nullable="false"/>
           </column>
           <column name="language_code" type="varchar(2)">\
               <constraints nullable="false"/>
           </column>
       </createTable>
    </changeSet>
</databaseChangeLog>

Using Liquibase, the following simple database schema is set up.

Setting up test data

Now to add test data, we can use a Liquibase feature that is called ‘context’. A context can be set on a certain changeset. We can specify during the test that we are using the context test and prod. This leads to Liquibase running changesets that have the context prod and test. For a production situation, we would be using the context prod only.

The context is specified in the application.properties. In our normal properties we would specify:

spring.liquibase.change-log=classpath:config/liquibase/liquibase-changelog.xml
spring.liquibase.drop-first=true
spring.liquibase.contexts=prod

We indicate where the base changeset file for Liquibase can be found. We set that all entities are dropped when running and we specify the context.

A dedicated Spring profile is used for testing, itest. Thus we can overrule the context in this profile, by creating an application-itest.properties file, which contains:

spring.liquibase.contexts=prod,test

We can then have a dedicated changeset for setting up test data. Below is a snippet.

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
       xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
       xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd
                       http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
   <changeSet author="devgs" id="202302191004100" contextFilter="test">
       <insert tableName="book" schemaName="bookstore">
           <column name="title" value="The Pragmatic Programmer"/>
           <column name="isbn" value="9780135957059"/>
           <column name="language_code" value="en"/>
       </insert>
    </changeSet>
</databaseChangeLog>

Running a test

Now that we have the database setup, and it’s up and running, we can execute our test. In this article, Spock is used for testing. The concise Groovy language together with the BDD setup of tests offer tests that have great readability.

The test is shown below.

@SpringBootTest
@ActiveProfiles("itest")
class BookStoreRepositoryTest extends Specification {

   static PostgreSQLContainer POSTGRES

   static {
       POSTGRES = (PostgreSQLContainer) new PostgreSQLContainer(PostgreSQLContainer.IMAGE)
               .withDatabaseName("bookstore")
               .withUsername("devgs")
               .withPassword("secret")
               .withExposedPorts(5432)
       POSTGRES.start()
       def jdbcUrl = POSTGRES.jdbcUrl
       LOG.info("Setting db url to '{}'", jdbcUrl)
       System.setProperty("spring.datasource.url", jdbcUrl)
   }

   private static final Logger LOG = LogManager.getLogger(BookStoreRepositoryTest.class);

   @Autowired
   BookStoreRepository bookStoreRepository;

   def 'When the test runs, then the database is loaded'() {
       given: 'Initial data has loaded'

       when: 'Getting the books of Erich Gamma'
       def books = bookStoreRepository.findByAuthor("Gamma")

       then: 'Design Patterns is returned'
       books.size() == 1
       books[0].title == 'Design Patterns'
   }
}

Conclusion

Using container technology, testcontainers and Liquibase offer an easy way to maintain a clean test environment for integration testing. No need to install databases, VMs with stubs etc. Issues regarding polluted test data due to older tests and previous development are no longer at hand. Developers can focus on fixing defects found by tests rather than investigating what’s wrong with the test data.

In terms of performance, the test runs fast.

Of course, this database scheme is rather simple, and the Spring application is very small. To ensure that the performance of the test is acceptable, an image can be created of the database itself, including the required structure. And then Liquibase would only run the setup of the test data.

The code of this sample can be found on GitHub.