When we implement a new feature in our Spring Boot application that interacts with a database or other services, we first have to write tests. But unfortunately, it is not easy to do.

I have used the H2 database or SQLite in my integration tests before. I believe most of us do this way. Unfortunately, it doesn’t work in most cases because the SQL syntax of database engines may differ, and our integration tests may vary as well. The first solution to this problem is to start the same database as the one used in production. However, we have some disadvantages of this solution, and one of them is you have to ask your DBA to create the database or tables that start, for example, with the prefix “test_” or something like that, to run your tests in the build server. In some cases, it is not easy to approach.

But there is a good solution. If you have installed a docker on your build server and your computer, you can create a real environment and launch your tests, even in your Junit tests. The solution is the excellent tool called Testcontainers. It has many advantages like:

  • You can launch the docker container (or database engine in docker if it has docker image) with a random port suitable for your tests won’t fail because of the port conflict.
  • You don’t have to clear your database each time because Testcontainers launches the empty DB.
  • You are not limited to use Testcontainers only for the database. You can include to your tests any docker from the docker hub. For these cases, you have to create with GenericContainer. This class has a constructor with a String parameter where you can pass your container name with its version like “dockerImageName:version”.

Let’s write some tests in Spring Boot. For instance, we have to test the DAO part of our application, like shown in the following. Here I am using Spring Data JPA and Hibernate as an ORM framework.

1
2
3
4
5
6
7
8
9
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

User findByUsername(String username);
}

and we will have an entity class here as well:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(unique = true)
private String username;
@Column(unique = true)
private String email;
@JsonIgnore
private String password;
private String firstName;
private String lastName;
private String phoneNumber;
}

Let’s assume we have to test this UserRepository, and we want to test findByUsername method. Actually we don’t have to test this method, because it is already well tested in Spring Data JPA I believe, but we will test it as an example.

First of all, we have to prepare database table called “User”. We could just set spring.jpa.hibernate.ddl-auto=update, but in production we won’t set it to “update”. It should be “none”. So that is why tables would not be created by Hibernate in our tests too. We will create it with the DB Migration tool called Liquibase.
Below I have created changeset for MySQL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
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-4.1.xsd">

<changeSet author="asadganiev" id="create-user-table">

<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="users"/>
</not>
</preConditions>

<createTable tableName="users">
<column name="id" type="bigint" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="username" type="varchar(50)">
<constraints unique="true" nullable="false"/>
</column>
<column name="email" type="varchar(50)">
<constraints unique="true" nullable="false"/>
</column>
<column name="password" type="varchar(64)">
<constraints nullable="false"/>
</column>
<column name="first_name" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="last_name" type="varchar(50)">
<constraints nullable="true"/>
</column>
<column name="phone_number" type="varchar(13)">
<constraints nullable="true"/>
</column>
</createTable>
</changeSet>

</databaseChangeLog>

I am not going to deep dive into creating changesets in Liquibase and configuring it in Spring Boot, because it is out of the scope of this topic. I don’t think that DB Migration tools are hard to learn. It is pretty straightforward if you are good at relational databases. Instead, we will focus on Testcontainers.

Let’s add Testcontainers BOM (Bill Of Materials) first:

1
2
3
4
5
6
7
8
9
10
11
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.15.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

and then Testcontainers and JUnit 5 dependency. Here we can omit the dependency version because we have added BOM already, and we will use the version from there.

1
2
3
4
5
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

And we are ready to start to write our integration test. The code can be downloaded from here.

We will need to add MySQL dependency for TestContainers as well:

1
2
3
4
5
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>

Let’s write a test for findByUsername method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = UserRepositoryTest.Initializer.class)
class UserRepositoryTest {

@Autowired
private UserRepository userRepository;

@Test
void findByUsername() {

User john = User.builder()
.username("johnsmith")
.email("johnsmith@example.com")
.password("john-some-secret-password")
.firstName("John")
.build();

User savedUser = userRepository.save(john);

assertEquals(1, savedUser.getId());
assertEquals(john, userRepository.getOne(1L));
assertEquals(john, userRepository.findByUsername("johnsmith"));

User anna = User.builder()
.username("annasmith")
.email("annasmith@example.com")
.password("anna-some-secret-password")
.firstName("Anna")
.build();

savedUser = userRepository.save(anna);

assertEquals(2, savedUser.getId());
assertEquals(anna, userRepository.getOne(2L));
assertEquals(anna, userRepository.findByUsername("annasmith"));
}

public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

@Container
private static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");

static {
mysql.start();
}

@Override
public void initialize(ConfigurableApplicationContext applicationContext) {

TestPropertyValues.of(
"spring.jpa.hibernate.ddl-auto=none",
"spring.datasource.initialization-mode=always",
"spring.datasource.platform=mysql",
"spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true",
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
"spring.datasource.url=" + mysql.getJdbcUrl(),
"spring.datasource.username=" + mysql.getUsername(),
"spring.datasource.password=" + mysql.getPassword(),
"spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect",
"spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true",
"spring.liquibase.change-log=classpath:/db/changelog/db.changelog-master.xml",
"spring.liquibase.contexts=test",
"logging.level.liquibase=INFO"
).applyTo(applicationContext);
}
}
}
  • In line 18, we are launching Spring Boot application with a random port, obviously for not to fail when the port is in use.
  • In line 19, we are configuring Spring Boot with our Initializer, which is the UserRepositoryTest inner class in our case.
  • In the Initializer class, we are launching MySQL, and after launch, we are setting properties in initialize method.
  • In the findByUsername method, we are just creating two separate users, saving them to the database, and getting data from the database by id and by findByUsername method and asserting results.

Let’s launch the test.

1
$ mvn clean test

Voila! And it passes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 57.796 s - in com.example.demo.repository.UserRepositoryTest
2021-05-04 16:41:38.447 INFO 15805 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor : Shutting down ExecutorService 'applicationTaskExecutor'
2021-05-04 16:41:38.448 INFO 15805 --- [extShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2021-05-04 16:41:38.452 INFO 15805 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2021-05-04 16:41:38.476 INFO 15805 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 01:07 min
[INFO] Finished at: 2021-05-04T16:41:38+05:00
[INFO] ------------------------------------------------------------------------
$