You have already learned about testing Spring Boot applications, and in this topic we are going to explore the issues of writing and running integration tests and how to simplify this process.
Integration tests
Both unit tests and integration tests play a vital role in creating reliable software. They differ in their scope, complexity, and the types of issues they can detect.
Unit tests are designed to test individual components or functions in isolation. They are typically simple, quick to write, and fast to run. We often use mocking in unit tests to separate the component being tested from its dependencies. This separation allows you to test the component's behavior in a controlled environment where you can easily simulate different scenarios. However, unit tests have their limitations:
They can't detect issues that arise from the interaction between components.
They can't detect issues with the environment (like the database or network).
Mocks can hide real-world issues if they don't accurately represent the behavior of the real dependencies.
Integration tests, conversely, are designed to test the interaction between components. They are typically more complex and slower to run than unit tests, but they can detect issues that unit tests can't. Here are some of the benefits of integration tests compared to unit tests and tests with mocking:
Integration tests allow you to test the behavior of your system as a whole, not just individual components. This can help you catch bugs that arise from the interaction between components, which unit tests can miss.
Integration tests allow you to test your system in an environment that's similar to the real world. This can help you catch issues that arise from the environment, like database or network issues.
Because integration tests don't rely on mocks and use real instances of your system's dependencies (like databases or web services), they can catch issues that arise from the interaction between your system and its dependencies. Mocks can sometimes hide these issues if they don't accurately represent the behavior of the real dependencies.
Integration tests can simulate end-to-end workflows, which can help you catch issues that arise from complex sequences of interactions between components.
However, setting up and running integration tests can pose several challenges.
Environments and other challenges
Let's see what challenges you might need to overcome on the path to robust software.
Environment consistency: You need to ensure that the testing environment matches the production environment as closely as possible. This includes the correct versions of databases, message brokers, and other external services used by the application. Any discrepancies can lead to tests passing in the test environment but failing in production, which defeats the purpose of testing.
Setup and teardown: For each test or group of tests, you need to set up the environment to the initial state required for the tests. After the tests, you must clean up the environment. Doing this manually can be time-consuming and error-prone.
Resource isolation: Running tests concurrently can lead to conflicts if they are not properly isolated. For example, if two tests are running at the same time and interacting with the same database, they could interfere with each other and cause unexpected failures.
Configuration management: Each external service that your application interacts with might require a specific configuration. Managing these configurations for each individual service can become quite complex.
Portability: It can be difficult to ensure that tests run consistently across different environments (like different developers' machines or CI/CD environments).
Fortunately, there are tools that help us address these challenges by providing a consistent, reproducible environment for running tests.
Testcontainers
Testcontainers is an open-source framework that provides lightweight, throwaway instances of databases, message brokers, web browsers, and generally anything that can run in a Docker container. It's designed to create a controlled environment for your integration tests, so you don't have to worry about the setup and teardown of your own infrastructure.
Testcontainers for Java is a library that integrates with JUnit tests and allows for easy and convenient use of Testcontainers in your Java or Spring Boot application tests. It manages the lifecycle of containers, ensuring they are set up and torn down properly, and it isolates resources to prevent conflicts between tests. This simplifies the process of writing and running integration tests, making them more reliable and easier to manage.
The primary benefit of Testcontainers is that it can help you avoid the need to mock out parts of your application that interact with databases or other back-end services. Instead, you can use real instances of these services that are automatically set up and torn down as part of your tests.
This can be very useful when you're writing integration tests. For example, if you're testing a service that interacts with a database, you could use Testcontainers to start a real database in a Docker container. Your service could then connect to this database, perform operations, and assert that the expected results are returned. After the test is finished, the database container will be automatically destroyed.
You must have Docker installed to be able to instantiate Docker containers on your local machine.
Practical example
Imagine you are developing a Spring Boot application using the PostgreSQL database. This is how a greatly simplified example of such an application might look.
Entity:
Java
@Entity
class Person {
@Id
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}Kotlin
@Entity
class Person(
@Id
var id: Long?,
var name: String
)Repository:
Java
interface PersonRepository extends CrudRepository<Person, Long> {
Optional<Person> findByName(String name);
}Kotlin
interface PersonRepository : CrudRepository<Person, Long> {
fun findByName(name: String): Person?
}Rest controller:
Java
@RestController
class PersonRestController {
private final PersonRepository repository;
PersonRestController(PersonRepository repository) {
this.repository = repository;
}
@GetMapping("/persons/{name}")
public ResponseEntity<Person> getPerson(@PathVariable String name) {
return ResponseEntity.of(repository.findByName(name));
}
}Kotlin
@RestController
class PersonRestController(private val repository: PersonRepository) {
@GetMapping("/persons/{name}")
fun getPerson(@PathVariable name: String): ResponseEntity<Person> {
return ResponseEntity.of(Optional.ofNullable(repository.findByName(name)))
}
}Now, let's write a couple of integration tests for this application:
Java
@SpringBootTest
@AutoConfigureMockMvc
public class ApplicationTestIT {
@Autowired
MockMvc mockMvc;
@Test
void getPerson_returnsPerson() throws Exception {
mockMvc.perform(get("/persons/Alice"))
.andExpectAll(
status().isOk(),
content().contentType(MediaType.APPLICATION_JSON),
content().json("{\"id\": 1,\"name\": \"Alice\"}")
);
}
@Test
void getPerson_returns404() throws Exception {
mockMvc.perform(get("/persons/Cecile"))
.andExpect(status().isNotFound());
}
}Kotlin
@SpringBootTest
@AutoConfigureMockMvc
class ApplicationTestIT(@Autowired val mockMvc: MockMvc) {
@Test
@Throws(Exception::class)
fun `getPerson() returns Person`() {
mockMvc.perform(get("/persons/Alice"))
.andExpectAll(
status().isOk,
content().contentType(MediaType.APPLICATION_JSON),
content().json("{\"id\": 1,\"name\": \"Alice\"}")
)
}
@Test
@Throws(Exception::class)
fun `getPerson() returns 404`() {
mockMvc.perform(get("/persons/Cecile"))
.andExpect(status().isNotFound)
}
}While developing this application, you likely use a locally or remotely deployed development database. Using it for tests can be problematic because it has an internal state that you have to consider and restore after the tests have run. It would be much more convenient to use a disposable database of the same version you use for development. Let's do it!
First, add the necessary dependencies to the build.gradle, build.gradle.kts or pom.xml file:
Gradle (Groovy)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
// Spring test starter
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// postgresql module of testcontainers, check out for the latest version
testImplementation 'org.testcontainers:postgresql:1.17.5'
}Gradle (Kotlin)
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Spring test starter
testImplementation("org.springframework.boot:spring-boot-starter-test")
// postgresql module of testcontainers, check out for the latest version
testImplementation("org.testcontainers:postgresql:1.17.5")
}Maven
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring test starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- postgresql module of testcontainers, check out for the latest version -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.17.5</version>
</dependency>
</dependencies>Second, let's set up the initial state of the database by putting a data.sql file in the src/test/resources/ folder:
CREATE TABLE person
(
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
INSERT INTO person (id, name)
VALUES (1, 'Alice'),
(2, 'Bob');And finally, configure the data source URL by adding these lines in the src/test/resources/application.properties file so that they apply only to tests:
spring.datasource.url=jdbc:tc:postgresql:14.8-alpine:///test_db
spring.sql.init.mode=alwaysHere, tc means that the datasource is provided by Testcontainers, postgresql:14.8-alpine is the name of the Docker image to be used (you can specify another image with a different version of PostgreSQL) and ///test_db indicates the localhost and the database name (you can choose any that suits your needs). In addition, we tell Spring Boot to initialize the database with the data.sql script we wrote.
Now we can run the tests and they should pass successfully:
A step further
Being able to run integration tests locally is great, but let's see how we can run them in practically any environment. As a showcase, we'll create a simple workflow to run the tests every time we push the project repository on GitHub. Create the .github/workflows folder in the root of your project and put this tests.yml file there:
name: Tests
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
- name: Run Gradle test task
run: ./gradlew clean test # in case of a Maven project, the command is mvn clean testNow, whenever you push the project to the GitHub repository, these GitHub Actions will start and the tests will run on a GitHub server. Here is the outcome:
As you can see, everything has run successfully. A fresh PostgreSQL database instance was automatically deployed on a remote server; and despite not configuring the application for that environment, integration tests successfully passed. You don't have to share any usernames, passwords or other configurations to run integration tests on any remote machine in a different environment, including on another developer's machine, on a dedicated test server, on GitHub, etc. This is the power of Testcontainers!
Conclusion
In this topic, we explored the importance and value of integration tests and the tools you can use to set up and run such tests. We discussed the Testcontainers framework and its advantages. In addition, we demonstrated practical use cases of writing and running integration tests on a local machine and on a remote server as part of a CI/CD workflow.