Caching has become an essential part in modern web applications. It helps us to reduce the load on an underlying datasource, reduces latency of responses, and saves costs when dealing with paid third-party APIs.
However, it is equally crucial to thoroughly test our application's caching mechanism to ensure its reliability and effectiveness. Failing to do so may lead to inconsistencies between the cache and the database in production, resulting in stale data being served to the client.
Unit tests, as valuable as they are, will not help us to accurately simulate the interactions between the application and the provisioned cache. We will not be able to uncover issues related to serialization, data consistency, cache population and invalidation. In this scenario, writing integration tests becomes absolutely necessary to correctly ensure the integrity of the caching mechanism we have employed.
In this article, we will explore how we can leverage Testcontainers to write integration tests for validating the caching mechanism in a Spring Boot application. The sample application we will be using integrates with Redis to cache data in front of a MySQL database.
The working code referenced in this article can be found on Github.
Sample Spring Boot Application Overview
Our sample codebase is a Java 21 Maven based Spring Boot application. Since I'm bored out of mind by using the same old generic examples, we'll be building a rudimentary wizard management system for Hogwarts. The service layer of our application is expected to expose the below functionalities:
- Creation of new wizard records in the database
- Retrieval of all wizard records from the database
To improve performance and reduce database calls, we will implement caching in our service layer. Wizard records will be cached upon initial retrieval, and the cache will be invalidated when new records are created, ensuring data consistency.
MySQL Database layer
The database layer of our application will operate on 2 tables, named hogwarts_houses
and wizards
. We will make use of Flyway
to manage our database migration scripts.
First, we define a script to create the required database tables:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | CREATE TABLE hogwarts_houses ( id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE wizards ( id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), name VARCHAR(50) NOT NULL, house_id BINARY(16) NOT NULL, wand_type VARCHAR(20), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT wizard_fkey_house FOREIGN KEY (house_id) REFERENCES hogwarts_houses (id) ); |
The above script creates two tables: hogwarts_houses
for storing house names and wizards
for storing wizard records. Each wizard created will be associated with a house, establishing a one-to-many relationship between the two tables.
NOTE: The flyway migration scripts by default need to be placed in our project's
src/main/resources/db/migration
folder.
In our application, we will map the above created database tables to @Entity
classes and create their corresponding repositories, extending Spring Data JPA's JpaRepository
. To remain true to the main agenda of the article, which is testing the caching mechanism, we will not go into the implementation details of each of our classes. However, the full working code can be referenced on Github.
Populating Data with Flyway Migration Scripts
To enable our application to work with some initial data, we will create separate Flyway migration scripts to populate both of our database tables:
1 2 3 4 | INSERT INTO hogwarts_houses (name) VALUES ('Gryffindor'), ('Slytherin'); |
1 2 3 4 5 6 7 8 | SET @gryffindor_house_id = (SELECT id FROM hogwarts_houses WHERE name = 'Gryffindor'); SET @slytherin_house_id = (SELECT id FROM hogwarts_houses WHERE name = 'Slytherin'); INSERT INTO wizards (name, house_id, wand_type) VALUES ('Harry Potter', @gryffindor_house_id, 'Phoenix feather'), ('Hermione Granger', @gryffindor_house_id, 'Dragon heartstring'), ('Tom Riddle', @slytherin_house_id, 'Phoenix feather') |
The SQL scripts first populate the hogwarts_houses
table, then use the inserted house IDs as foreign keys to create corresponding wizard records in the wizards
table.
This step of populating data into our tables will help us when we are writing integration tests, as Flyway will automatically execute the defined scripts in the database container started by Testcontainers giving us a ready to use canvas for testing.
Spring Boot Service Layer
Now, in order to expose the functionalities we discussed at the beginning of this article, we will be creating a service class that will interact with both the MySQL database and the Redis cache:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @Service @RequiredArgsConstructor public class WizardService { private final WizardRepository wizardRepository; private final SortingHatUtility sortingHatUtility; @Cacheable(value = "wizards") public List<WizardDto> retrieve() { return wizardRepository.findAll().stream().map(this::convert).toList(); } @CacheEvict(value = "wizards", allEntries = true) public void create(@NonNull final WizardCreationRequestDto wizardCreationRequest) { final var house = sortingHatUtility.sort(); final var wizard = new Wizard(); wizard.setName(wizardCreationRequest.getName()); wizard.setWandType(wizardCreationRequest.getWandType()); wizard.setHouseId(house.getId()); wizardRepository.save(wizard); } // other service methods } |
The retrieve()
method of our service layer fetches all wizard records stored in the database. We have annotated this method with @Cacheable
, indicating that the returned result should be cached against the key wizards
. On subsequent invocations, the cached result would be returned without querying the database, unless the cache is invalidated.
And the create()
method saves a new wizard record in the database based on the details provided in the argument. It is annotated with @CacheEvict
, specifying that upon successful execution, the cache should be invalidated i.e the entries stored against the key wizards
should be deleted. This ensures that any subsequent invocations of the retrieve()
method will query the database, ensuring consistency between our database and cache.
Testing Caching Mechanism
With our caching mechanism implemented, let's proceed to ensure its correctness with some tests!.
To write integration tests for our application, we will start by looking at the required dependencies in our pom.xml
file:
1 2 3 4 5 6 7 8 9 10 11 | <!-- Test dependencies --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mysql</artifactId> <scope>test</scope> </dependency> |
The Spring Boot Starter Test (a.k.a. the swiss-army knife for testing) gives us the basic testing toolbox as it transitively includes JUnit, AssertJ, Mockito and other utility libraries, that we will require for writing assertions and running our tests.
And Testcontainers' MySQL module which will allow us to run a MySQL instance inside a disposable Docker container, providing an isolated environment for our integration tests.
This module transitively includes the core Testcontainers library, which we will use to start a Redis instance as well. Although there is no dedicated module for Redis, the generic container support in Testcontainers will allow us to easily set up and manage a Redis container for our tests.
Additionally, we will use the Maven Failsafe Plugin to run our integration tests by adding the following plugin configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-failsafe-plugin</artifactId> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> </plugin> </plugins> </build> |
The Maven Failsafe Plugin is designed to run integration tests separately from unit tests. The plugin executes test methods in classes whose names end with IT
(a convention for integration test classes).
Prerequisite: Running Docker
The prerequisite for running the required Redis and MySQL instances via Testcontainers is, as you've guessed it, an up-and-running Docker instance. We need to ensure this prerequisite is met when running the test suite either locally or when using a CI/CD pipeline.
Starting Redis and MySQL Containers via Testcontainers
We need to start a Redis container for caching and MySQL database container to act as the underlying datasource, in order for our application context to start up correctly during text execution. As mentioned previously, since Testcontainers does not have a dedicated module for Redis, we will be using the GenericContainer
class which allows us to start any Docker image:
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 | @SpringBootTest class WizardServiceIT { private static final int REDIS_PORT = 6379; private static final String REDIS_PASSWORD = RandomString.make(10); private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.2.4"); private static final GenericContainer<?> REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE) .withExposedPorts(REDIS_PORT) .withCommand("redis-server", "--requirepass", REDIS_PASSWORD); private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8"); private static final MySQLContainer<?> MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE); static { REDIS_CONTAINER.start(); MYSQL_CONTAINER.start(); } @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT)); registry.add("spring.data.redis.password", () -> REDIS_PASSWORD); registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl); registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername); registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword); } // test cases } |
With this setup, we successfully start the declared Redis and MySQL containers along with defining the required configuration properties needed by our application to connect to these instances using @DynamicPropertySource
. The containers will be destroyed automatically once the test class completes execution.
It is also important to remember that the Flyway migration scripts we defined earlier in our src/main/resources/db/migration
folder will be automatically executed on application startup during test execution. This will allow us to test our application's caching behaviour with the assumption that wizard records already exist in the database.
Writing Test Cases with Spring Boot
Now that we have configured the required cache and database containers successfully, we can test the functionalities we exposed earlier in our WizardService
class with appropriate test cases:
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 | @SpringBootTest class WizardServiceIT { // Testcontainers setup as seen above @Autowired private WizardService wizardService; @SpyBean private WizardRepository wizardRepository; @Test void shouldRetrieveWizardRecordFromCacheAfterInitialDatabaseRetrieval() { // Invoke method under test initially var wizards = wizardService.retrieve(); assertThat(wizards).isNotEmpty(); // Verify that the database was queried verify(wizardRepository, times(1)).findAll(); // Verify subsequent reads are made from cache and database is not queried Mockito.clearInvocations(wizardRepository); final var queryTimes = 100; for (int i = 1; i < queryTimes; i++) { wizards = wizardService.retrieve(); assertThat(wizards).isNotEmpty(); } verify(wizardRepository, times(0)).findAll(); } } |
In the above test case, we verify that after the initial invocation of the retrieve()
method, subsequent invocations should fetch data from the cache instead of querying the database.
We retrieve all wizard records initially and verify that the database was indeed queried using Mockito. We are able to do this since we have declared WizardRepository
as a @SpyBean
in our test class. Now, we invoke the service layer multiple times repeatedly to assert that there are zero interactions with the database, confirming that the initial result was cached and is being returned subsequently.
Now let's move ahead and verify that our cache is invalidated successfully on new wizard creation:
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 | @Test void shouldInvalidateCachePostWizardCreation() { // Populate cache by retrieving wizard records var wizards = wizardService.retrieve(); assertThat(wizards).isNotEmpty(); // Prepare wizard creation request final var name = RandomString.make(); final var wandType = RandomString.make(); final var wizardCreationRequest = new WizardCreationRequestDto(); wizardCreationRequest.setName(name); wizardCreationRequest.setWandType(wandType); // Invoke method under test wizardService.create(wizardCreationRequest); // Retrieve wizards post creation and verify database interaction Mockito.clearInvocations(wizardRepository); wizards = wizardService.retrieve(); verify(wizardRepository, times(1)).findAll(); // assert the fetched response contains new wizard data assertThat(wizards) .anyMatch( wizard -> wizard.getName().contentEquals(name) && wizard.getWandType().contains(wandType)); } |
In the test case, we first invoke the retrieve()
method in order to populate the cache with wizard records. Then we proceed to invoke the create()
method of our service class with a sample creation request. Now, in order to test cache invalidation, we fetch the wizard records again and verify that the database is queried, confirming that the cache was invalidated due to wizard creation.
The above written test cases, when executed together, will… drumroll please … fail!
This is due to the fact that we do not clean up the state modified by our test cases. When running multiple test cases that involve caching, it is important to ensure that each test case is independent and is not affected by the cached data from previous tests. To achieve this, we need to clear the cache before each test case is executed:
1 2 3 4 5 6 7 | @Autowired private CacheManager cacheManager; @BeforeEach void setup() { cacheManager.getCache("wizards").invalidate(); } |
By injecting the CacheManager
bean and defining a setup()
method annotated with @BeforeEach
, we ensure that the cache is invalidated before each test case execution, guaranteeing that each test starts with an empty cache, providing a consistent environment for testing our caching behavior.
By writing the above comprehensive test cases, we ensure that our service layer functions correctly and interacts with the provisioned cache and database as expected. We can confidently deploy our application to production environments, knowing that it has been thoroughly tested and validated against a simulated environment.
Pro-tip: Reduce Boilerplate Testcontainer Code
In our WizardServiceIT
test class, we had written a long Testcontainers setup to start the required Redis and MySQL containers before we could start with our test cases.
Imagine a scenario where multiple test classes require the same containers to be started in addition to our existing class. Copying and pasting the boilerplate setup code across these classes would lead to code duplication and increased execution time since individual instances of Redis and MySQL will be started for each test class.
To solve these issues, check out the (upcoming) article: Reducing Testcontainers Execution Time and Boilerplate Code Using JUnit 5's Lifecycle Callbacks. It provides a detailed solution to reduce boilerplate setup code and optimize Testcontainers execution time in our test suite.
Conclusion: Testing Caching with Spring Boot and Testcontainers
In this article, we have successfully explored on how we can use Testcontainers to write integration tests for validating our Spring Boot application's caching mechanism.
We gain confidence in deploying our application to production, knowing that scenarios of cache population, serialization/deserialization, data consistency, and cache invalidation have been validated against a simulated environment consisting of both the cache and the underlying database layer.
Although we have used Redis as a cache in our example, it's important to note that Testcontainers can be used to initialize and test against any cache that our application employs (given it has a docker image ), such as Memcached, Hazelcast, etc.
As always, the complete source code demonstrated throughout this article is available on Github.
Joyful testing,