This is the second part of the Spring Boot Testing article series. The code snippets are taken from this repository. You can clone it and run tests to see how it works.
Previous Chapter. Spring Boot Testing — Data and Services
So, we have learned how to test the service layer and the repository layer with the H2 database. Such tests have some benefits. For example, they are pretty fast and require no complex configuration neither on the local machine nor in the CI/CD environment. However, H2 is not the database that usually runs in production. If we use some vendor-specific features of one database, H2 might not help us. So, today we are going to discuss how to run test cases against the true DB server.
Automatic Schema Creation
How can we run tests with the real database? Well, we could create a new instance locally and configure the test environment by editing src/test/resources/application.yml
. It does work but it makes the build hard to reproduce. Every developer that is working on the project has to be sure that they have two separate databases. One for development and another one for tests running. Besides, it makes executing the build on CI/CD environment a real challenge.
So, Testcontainers to the rescue! It’s a Java library that launches the service within the Docker container, runs tests, and eventually destroys the container. You don’t need to worry about anything, the framework does the job. Just make sure you have Docker installed and then you are ready to go. The library supports dozens of different databases and modules (PostgreSQL, MySQL, MongoDB, Kafka, Elasticsearch, Nginx, Toxiproxy, and many others). Even if you didn’t find the one that you need, you could use generic container creation.
The first step is to add the required dependencies.
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
runtimeOnly 'org.postgresql:postgresql'
Then we need to create a separate configuration file in src/test/resources
. Spring Boot is able to distinguish different configuration files by the profiles. The profile name should be placed as a suffix like application-PROFILE_NAME.yml
. For example, the configuration file named application-test-containers.yml
is applied only when test-containers
profile is active.
spring:
datasource:
url: jdbc:tc:postgresql:9.6.8:///test_database
username: user
password: password
jpa:
hibernate:
ddl-auto: create
Have you noticed the tc
suffix in the JDBC-connection string? That’s the magic that comes with the union of JUnit 5 and Testcontainers. The thing is that you don’t need any programmatic configurations at all! When the framework sees that url
contains the tc
suffix it runs all necessary Docker commands internally. You can find more examples here.
We set
spring.jpa.hibernate.ddl-auto=create
property
so the database schema shall be created automatically
according to definition of entity classes.
Flyway integration is described in the next section.
Now let’s take a look at PersonCreateService.createFamily
method and its H2 test again.
@Service
@RequiredArgsConstructor
public class PersonCreateServiceImpl implements PersonCreateService {
private final PersonValidateService personValidateService;
private final PersonRepository personRepository;
@Override
@Transactional
public List<PersonDTO> createFamily(Iterable<String> firstNames, String lastName) {
final var people = new ArrayList<PersonDTO>();
firstNames.forEach(firstName -> people.add(createPerson(firstName, lastName)));
return people;
}
@Override
@Transactional
public PersonDTO createPerson(String firstName, String lastName) {
personValidateService.checkUserCreation(firstName, lastName);
final var createdPerson = personRepository.saveAndFlush(
new Person()
.setFirstName(firstName)
.setLastName(lastName)
);
return DTOConverters.toPersonDTO(createdPerson);
}
}
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureTestDatabase
class PersonCreateServiceImplSpringBootTest {
@Autowired
private PersonRepository personRepository;
@MockBean
private PersonValidateService personValidateService;
@Autowired
private PersonCreateService personCreateService;
@BeforeEach
void init() {
personRepository.deleteAll();
}
@Test
void shouldCreateOnePerson() {
final var people = personCreateService.createFamily(
List.of("Simon"),
"Kirekov"
);
assertEquals(1, people.size());
final var person = people.get(0);
assertEquals("Simon", person.getFirstName());
assertEquals("Kirekov", person.getLastName());
assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now()));
}
@Test
void shouldRollbackIfAnyUserIsNotValidated() {
doThrow(new ValidationFailedException(""))
.when(personValidateService)
.checkUserCreation("John", "Brown");
assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily(
List.of("Matilda", "Vasya", "John"),
"Brown"
));
assertEquals(0, personRepository.count());
}
}
How do we run the test with Testcontainers PostgreSQL instance? All we have to do is to add two annotations. @AutoConfigureTestDatabase
though should be removed.
-
@ActiveProfiles("test-containers")
— activates thetest-containers
profile so the Spring could read the configuration file that was described earlier -
@Testcontainers
— tells to run PostgreSQL instance in Docker automagically
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@Testcontainers
@ActiveProfiles("test-containers")
class PersonCreateServiceImplTestContainers {
@Autowired
private PersonRepository personRepository;
@MockBean
private PersonValidateService personValidateService;
@Autowired
private PersonCreateService personCreateService;
@BeforeEach
void init() {
personRepository.deleteAll();
}
@Test
void shouldCreateOnePerson() {
final var people = personCreateService.createFamily(
List.of("Simon"),
"Kirekov"
);
assertEquals(1, people.size());
final var person = people.get(0);
assertEquals("Simon", person.getFirstName());
assertEquals("Kirekov", person.getLastName());
assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now()));
}
@Test
void shouldRollbackIfAnyUserIsNotValidated() {
doThrow(new ValidationFailedException(""))
.when(personValidateService)
.checkUserCreation("John", "Brown");
assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily(
List.of("Matilda", "Vasya", "John"),
"Brown"
));
assertEquals(0, personRepository.count());
}
}
See? Piece of cake!
Repository Tests
What about testing repository layers? Let’s see H2 example again.
@DataJpaTest
class PersonRepositoryDataJpaTest {
@Autowired
private PersonRepository personRepository;
@Test
void shouldReturnAlLastNames() {
personRepository.saveAndFlush(new Person().setFirstName("John").setLastName("Brown"));
personRepository.saveAndFlush(new Person().setFirstName("Kyle").setLastName("Green"));
personRepository.saveAndFlush(new Person().setFirstName("Paul").setLastName("Brown"));
assertEquals(Set.of("Brown", "Green"), personRepository.findAllLastNames());
}
}
The rules are the same but there is a slight difference. @DataJpaTest
is annotated with @AutoConfigureTestDatabase
itself. This annotation replaces any data source with the H2 instance by default. So, we need to override this behavior by adding replace=Replace.NONE
property.
@DataJpaTest
@Testcontainers
@ActiveProfiles("test-containers")
@AutoConfigureTestDatabase(replace = Replace.NONE)
class PersonRepositoryTestContainers {
@Autowired
private PersonRepository personRepository;
@Test
void shouldReturnAlLastNames() {
personRepository.saveAndFlush(new Person().setFirstName("John").setLastName("Brown"));
personRepository.saveAndFlush(new Person().setFirstName("Kyle").setLastName("Green"));
personRepository.saveAndFlush(new Person().setFirstName("Paul").setLastName("Brown"));
assertEquals(Set.of("Brown", "Green"), personRepository.findAllLastNames());
}
}
Everything still works fine.
Flyway Integration
The Evolutionary Database Design principle has been described a long time ago. Nowadays it is standard routine to use tools that implement this pattern. Flyway and Liquibase are the most popular ones in the Java world. We are going to integrate Testcontainers with Flyway.
Firstly, the Flyway dependency is required.
implementation "org.flywaydb:flyway-core"
Secondly, it’s necessary to disable Flyway in application-test-containers.yml
because there will be a separate configuration file.
spring:
datasource:
url: jdbc:tc:postgresql:9.6.8:///test_database
username: user
password: password
jpa:
hibernate:
ddl-auto: create
flyway:
enabled: false
Then we are going to create application-test-containers-flyway.yml
. The library provides lots of auto-configuration. So, actually, we don’t need to tune anything.
spring:
datasource:
url: jdbc:tc:postgresql:9.6.8:///test_database
username: user
password: password
Now it’s time to add SQL migrations. The default directory is resources/db/migration
.
create table person
(
id serial primary key,
first_name text,
last_name text,
date_created timestamp with time zone
);
Finally, we need to replace test-containers
profile with test-containers-flyway
one.
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@Testcontainers
@ActiveProfiles("test-containers-flyway")
class PersonCreateServiceImplTestContainersFlyway {
@Autowired
private PersonRepository personRepository;
@MockBean
private PersonValidateService personValidateService;
@Autowired
private PersonCreateService personCreateService;
@BeforeEach
void init() {
personRepository.deleteAll();
}
@Test
void shouldCreateOnePerson() {
final var people = personCreateService.createFamily(
List.of("Simon"),
"Kirekov"
);
assertEquals(1, people.size());
final var person = people.get(0);
assertEquals("Simon", person.getFirstName());
assertEquals("Kirekov", person.getLastName());
assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now()));
}
@Test
void shouldRollbackIfAnyUserIsNotValidated() {
doThrow(new ValidationFailedException(""))
.when(personValidateService)
.checkUserCreation("John", "Brown");
assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily(
List.of("Matilda", "Vasya", "John"),
"Brown"
));
assertEquals(0, personRepository.count());
}
}
CI/CD Tests Running
Although Testcontainers’ purpose is to make tests easy to run there are some caveats in CI/CD environment build. Some vendors integrate with Testcontainers transparently. For example, Travis-CI spots the requirement to run a container automatically and does the job internally. But some other tools may require additional configuration — e.g., Jenkins.
Docker Wormhole
It’s a common approach to package the whole application build in a Docker image during the CI run. Testcontainers can handle it and run the defined containers on the host Docker server. In this case, we need to bind /var/run/docker.sock
as a volume. More than that, the directory inside the container should be the same as the one where the container was launched.
For instance, that’s a hypothetical command to run tests on a CI environment with gradle
.
docker run -it \
--rm \
-v $PWD:$PWD \
-w $PWD \
-v /var/run/docker.sock:/var/run/docker.sock gradle:7.1-jdk8 \
gradle test
You can find more information on the official Testcontainers guide page.
Conclusion
Today we have discussed how to test the data and service layer with Testcontainers and Flyway. Next time we will test the API (controllers) level. If you have any questions or suggestions, please, leave your comments down below. Thanks for reading!
Top comments (0)