Testing Caching Mechanism with Testcontainers in Spring Boot

Last Updated:  June 7, 2024 | Published: June 7, 2024

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:

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:

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:

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 wizardsshould 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:

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:

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:

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:

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:

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:

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,

Hardik Singh Behl
Github | LinkedIn | Twitter

>