Integration Test with Testcontainers
in Spring Boot
Make sure to read the part 1.
Integration testing is the phase in software testing in which individual software modules are combined and tested as a group. This testing approach is designed to uncover any issues that may arise from the interactions between components, such as data flow and compatibility problems. Integration tests can be performed at different levels of the software development process and are a critical step towards ensuring the reliability and functionality of a software product.
We may have several components, such as controllers, services, and repositories, that work together to provide a certain functionality.
Using a throwaway database is recommended for integration testing as integration tests often require the manipulation of data in the database. With that being said, any existing database is definitely not recommended to use for integration tests due to:
- Data loss: Integration tests often involve inserting, updating, and deleting data in the database. If we use the existing database for testing, we risk losing important data that is crucial for business operations.
- Data inconsistency: If the integration tests modify data in the existing database, it can affect the consistency of the data. This can lead to issues such as inaccurate reporting or incorrect behavior of our application.
- Security: Using the existing database for integration tests can also pose security risks. For example, if we accidentally expose sensitive data or credentials during testing, it can be a security breach.
Existing database refers to the database that is already in use for the production or other environments. It is the database that contains the actual data that is being used by the application for its regular operations, or by the testers.
To avoid these risks, we must use a throwaway database for integration testing as it can be reset to a known state before each test run, ensuring that the tests are reliable and consistent.
We can easily create a throwaway database that is created specifically for running tests and is discarted after the tests are completed.
Testcontainers is a Java library that provides throwaway instances of common databases that can be used for integration testing. When using Testcontainers, we can start a throwaway database container at the beginning of the test suite, and then stop and remove the container once the test suite is completed. This ensures that the tests are run in a clean and isolated environment without affecting the production data or environment. The throwaway database can be initialized with specific schema and data that is required for the integration tests, and then destroyed after the tests are complete. In our application, we used Flyway to create schemas. This approach allows for repeatable, isolated and predictable integration tests.
Enough for theory knowledge. Let’s dive into code.
We are going to use the project that we created for entity revisions. For the complete code for integration tests, you can find in branch named integration-tests
here.
Docker must be running to use testcontainers
. Otherwise an error will be shown such as java.lang.IllegalStateException: Could not find a valid Docker environment
.
First things first, we must install the required dependencies in build.gradle
.
dependencies {
...
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
}
dependencyManagement {
imports {
mavenBom "org.testcontainers:testcontainers-bom:1.17.6"
}
}
We add 3 test dependencies and dependencyManagement
. We could install the dependencies without using the dependencyManagement
by adding the version at the end of each testImplementation
, but dependencyManagement allows to centralize the management of dependency versions without specifying the version for each child project (testcontainers
, juint-jupiter
, postgresql
).
Then, we need to initialize DataSource
for the Application context. Let’s write an abstract class for it, so we can extend it for each repository class.
// Annotation for a JPA test that focuses only on JPA components.
@DataJpaTest
// JUnit-Jupiter extension which automatically starts and stops the containers that are used in the tests.
@Testcontainers
// A class-level annotation that is used to declare which active bean definition profiles should be used when loading an ApplicationContext for test classes.
@ActiveProfiles("medium")
// Tells Spring not to replace the application default DataSource.
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// Defines class-level metadata that is used to determine how to load and configure an ApplicationContext for integration tests.
@ContextConfiguration(initializers = BaseRepositoryTest.DataSourceInitializer.class)
public abstract class BaseRepositoryTest {
// Annotation used in conjunction with the Testcontainers annotation to mark containers that should be managed by the Testcontainers extension.
@Container
private static final PostgreSQLContainer<?> database = new PostgreSQLContainer<>("postgres:15.2");
public static class DataSourceInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
/*
`initialize` function allows us to set properties dynamically. Since the DataSource is initialized dynamically,
we need to set url, username, and password that is provided/set by the testcontainers.
*/
@Override
public void initialize(@NotNull ConfigurableApplicationContext applicationContext) {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
applicationContext,
"spring.test.database.replace=none", // Tells Spring Boot not to start in-memory db for tests.
"spring.datasource.url=" + database.getJdbcUrl(),
"spring.datasource.username=" + database.getUsername(),
"spring.datasource.password=" + database.getPassword()
);
}
}
}
Then let’s create our properties file for tests. The profile name must match with the value of @ActiveProfiles
.
# application-medium.properties
# Enables logging for SQL statements.
spring.jpa.show-sql=true
# Formats the logged SQL statement.
spring.jpa.properties.hibernate.format_sql=true
# Name of the target database to operate on.
spring.jpa.databasePlatform=org.hibernate.dialect.PostgreSQLDialect
...
If you enabled jpa auditing for entities, you must declare a test configuration and import it as well. Otherwise, you will get a ConstraintViolationException
exception if any column for auditing is set to NOT NULL
. Even if the columns are not set to NOT NULL
, their value will always be null
.
ERROR: null value in column “created_by” of relation “users” violates not-null constraint.
To resolve this issue, we need to create a test configuration for JPA auditing.
// Defines beans or customizations for a test.
@TestConfiguration
// Enables JPA auditing. auditorAwareRef value must match with the name of AuditorAware bean.
@EnableJpaAuditing(auditorAwareRef = "testAuditProvider")
public class TestAuditingConfig {
public static final String TEST_AUDITOR = "Test Auditor";
@Bean
@Primary
public AuditorAware<String> testAuditProvider() {
return () -> Optional.of(TEST_AUDITOR);
}
}
Then simply import it in BaseRepositoryTest
such as
...other annotations.
@Import({TestAuditingConfig.class, ...other classes that may cause UnsatisfiedDependencyException.})
public abstract class BaseRepositoryTest {
...
}
We can also validate the auditor to check if the configuration works fine.
class AuditProviderTest extends BaseRepositoryTest {
@Autowired
private AuditorAware<String> auditorAware;
@Test
@DisplayName("Validates the current test auditor.")
void validateAuditor() {
String currentAuditor = auditorAware.getCurrentAuditor()
.orElse(null);
Assertions.assertEquals(TestAuditingConfig.TEST_AUDITOR, currentAuditor);
}
}
As we completed the configuration for our test database, let’s write a repository test to validate if we are able to create a record in the throwaway db.
import com.example.demo.base.BaseRepositoryTest;
import com.example.demo.entity.UserEntity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
// Annotation for a JPA test that focuses only on JPA components.
@DataJpaTest
// Annotation used to declare a custom display name for the annotated test class or test method.
@DisplayName("User repository tests.")
public class UserRepositoryTest extends BaseRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void createUser() {
UserEntity userEntity = new UserEntity();
userEntity.setName("My name");
userEntity.setSurname("My surname");
userEntity.setEmail("name@surname.com");
// Saves & flushes the entity instantly.
userRepository.saveAndFlush(userEntity);
// Returns all entities to check the actual result with the expected.
List<UserEntity> userEntities = userRepository.findAll();
// Checks if the table has only one record, which we have just created.
assertEquals(1, userEntities.size());
}
}
If everything goes well, our test code creates a record in the db with auditing.
If you want to remove records each time a test is run, you can implement a function with @AfterEach
annotation.
@AfterEach
void tearDown() {
userRepository.deleteAll();
}
We have successfully created a test for our UserRepository
. Now, let’s create a couple of tests for UserService
.
For testing service components, we begin with writing an abstract class, so that we can extend it for each service component. It will also help us prevent duplicated code blocks or annotations.
@DataJpaTest
@Import({ObjectMapper.class})
public abstract class BaseServiceTest extends BaseRepositoryTest {
}
If you get an UnsatisfiedDependencyException
error, you can define base package scan at the class level for the package where the service classes are kept.
@ComponentScan(basePackages = {“com.example.demo.service”})
@DataJpaTest
@Import({ObjectMapper.class})
@ComponentScan(basePackages = {"com.example.demo.service"})
public abstract class BaseServiceTest extends BaseRepositoryTest {
class UserServiceTest extends BaseServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Autowired
private PlatformTransactionManager platformTransactionManager;
@AfterEach
void tearDown() {
userRepository.deleteAll();
}
@Test
void create_emailExists() {
String email = "email@user.com";
UserDto userOne = this.createUserDto(email);
userOne = userService.create(userOne);
assertNotNull(userOne);
assertNotNull(userOne.getId());
UserDto userTwo = this.createUserDto(email);
assertEquals(userOne.getEmail(), userTwo.getEmail(), "Emails must be same to check if the email exists.");
EntityExistsException thrown = assertThrows(EntityExistsException.class,
() -> userService.create(userTwo), "Expected javax.persistence.EntityExistsException while registering the 2nd user.");
assertTrue(StringUtils.hasLength(thrown.getMessage()));
}
@Test
void update_success() {
UserDto userOne = this.createUserDto();
userOne = userService.create(userOne);
UUID userId = userOne.getId();
assertNotNull(userOne);
assertNotNull(userId);
UserDto updatedUserDto = new UserDto.Builder()
.id(userId)
.name(userOne.getName())
.surname("Updated surname")
.email("new@email.com")
.build();
updatedUserDto = userService.update(userId, updatedUserDto);
assertNotNull(updatedUserDto);
assertNotEquals(userOne.getSurname(), updatedUserDto.getSurname(), "Surname must not be the old one.");
assertNotEquals(userOne.getEmail(), updatedUserDto.getEmail(), "Email must not be the old one.");
}
@Test
void getUserRevisionsById_success() {
TransactionTemplate tx = new TransactionTemplate(platformTransactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED);
UserDto userDto = this.createUserDto();
UserEntity userEntity = UserMapper.INSTANCE.toEntity(userDto);
// Stores the new entity.
tx.execute(status -> userRepository.save(userEntity));
// Gets the user revision after creation.
List<UserDto> userRevisions = userService.getUserRevisionsById(userEntity.getId());
assertNotNull(userRevisions);
assertEquals(1, userRevisions.size(), "There must be one revision right after record creation.");
// Updates the user's name.
userEntity.setName("New name");
// Stores the updated entity.
tx.execute(status -> userRepository.save(userEntity));
// Gets the user revision after updating the entity.
userRevisions = userService.getUserRevisionsById(userEntity.getId());
assertNotNull(userRevisions);
assertEquals(2, userRevisions.size(), "There must be two revisions for both creating and updating the user.");
}
}
Once all tests are written, we can run ./gradlew clean test
in terminal to see if all tests are executed and successful.
In conclusion, integration testing is essential to ensure the reliability and functionality of our software. To conduct effective tests, we should use a throwaway database instead of the existing one. Using Testcontainers, a Java library, we can create throwaway database instances for testing, keeping our production data safe from potential risks. By initializing the throwaway database with the necessary schema and data, we ensure consistent and repeatable tests. Flyway can help create schemas for a more robust testing environment.
Interested in digging deeper? You can clone the GitHub repository, and debug the sample project to see how it works.