Spring boot caching integration tests, using SpringBootTest and Mokito SpyBean annotations

Benaya Trabelsi
5 min readJun 26, 2023

In the previous(but not directly related) articles, I created an ETL for phishing data, from multiple open-source datasets to MongoDB. In this article, I want to start working on the other side — the API consuming the database.

The focus here will be exclusively on testing the spring boot caching mechanism, and not the API nor the actual database. the integration test class won’t be long, and I’ll add some pointers to clear up the cumber stones I stumbled upon.

To start, let's add the cache starter dependency to our pom:

  <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

In the main class, let’s add the EnableCaching annotation:

@SpringBootApplication
@EnableCaching
public class LinkValidatorApiApplication {
public static void main(String[] args) {
SpringApplication.run(LinkValidatorApiApplication.class, args);
}
}

Caching service — the service tested

To demonstrate the integration tests, I created a simple caching service using the Cacheable annotation to cache objects, and CacheEvict annotation to clear the cache.

First, let's see the tested CachingService class we will test:

@Service
@RequiredArgsConstructor
public class CachingService {
private final DomainRepository domainRepository;

@Cacheable(value = "urlCache", key = "#url", unless = "#result == true")
public boolean isSafeUrlByDirectSearch(String url) {
return !domainRepository.existsByUrlsContains(url);
}
@Cacheable(value = "domainCache", key = "#domain", unless = "#result == null")
public Domain getDomainFromCache(String domain){
return domainRepository.findById(domain).orElse(null);
}
@Cacheable(value = "domainCacheExist", key = "#domain", unless = "#result == false")
public boolean isUnsafeDomainCached(String domain){
return domainRepository.existsByName(domain);
}
@Scheduled(fixedDelay = 5, timeUnit = java.util.concurrent.TimeUnit.MINUTES)
@CacheEvict(value = {"urlCache, domainCacheExist, domainCache"}, allEntries = true)
public void cacheEvict() {
}
}

Since I use the default spring boot cache, I set the CacheEvict method to clear the cache every 5 minutes. Otherwise, if you use a cache provider like Redis, you can use the CacheManager to set up the TTL for each cache.

Let’s examine one of the Cacheable annotations:

@Cacheable(value = "urlCache", key = "#url", unless = "#result == true")

This annotation indicates that the result of the following method will be cached, to the “urlCache” cache name, and the URL itself as the cache key, using another spEL placeholder. Notice the “unless” property, which adds a spEL condition to caching the result.

A few things I’ve learned the hard way when implementing the caching mechanism —

  • On runtime, spring boot uses an AOP proxy to make the calls, just like any spring AOP annotation. The proxy intercepts the method calls and returns the cached value when relevant, instead of calling the actual method. As great as it is, it also means that the caching mechanism can only be called from outside the implementing class! if called from inside the same class, the actual method will be called, and not the proxy — and the caching won’t happen.
  • Don’t use the same cache name(the “value” param) for different methods. it may not produce any error, but the results can mix together making the cache unreliable.
  • If you have multiple caching methods, group them in a separate service. It can save a lot of debugging time.

CachingServiceIntegrationTest — the testing class

In order to test the caching mechanism, ordinary spring boot unit tests aren’t enough, because we must have spring context to use Spring AOP, and specifically the cache proxy. To get the spring context, I used the SpringBootTest annotation, and to follow the number of repository calls, I used the SpyBean annotation on the repository, giving it Mokito abilities.

The test class code:

@SpringBootTest
public class CachingServiceIntegrationTest {
private static final String UNSAFE_URL = "https://a.tb52ebklo.repl.co/service/https:/www.facebook.com/unsupporte";
private static final String UNSAFE_DOMAIN = "a.tb52ebklo.repl.co";
private static final String SAFE_URL = "https://www.facebook.com";
private static final String SAFE_DOMAIN = "www.facebook.com";

@Autowired
private CachingService cachingService;

@SpyBean
private DomainRepository repository;

@BeforeEach
public void setUp() {
repository.save(Domain.builder().name(UNSAFE_DOMAIN).urls(List.of(UNSAFE_URL)).build());
}
@AfterEach
public void tearDown() {
repository.deleteAll();
cachingService.cacheEvict();
}
@Test
public void testIsSafeUrlByDirectSearch_WhenUrlExist_ShouldBeCached() {
AtomicInteger unsafeCounter = new AtomicInteger(0);
IntStream.range(0, 100).forEach(i -> unsafeCounter
.updateAndGet(value -> cachingService
.isSafeUrlByDirectSearch(UNSAFE_URL) ? value : value + 1));
Assertions.assertEquals(unsafeCounter.get(), 100);
verify(repository, times(1)).existsByUrlsContains(UNSAFE_URL);
}

@Test
public void testIsSafeUrlByDirectSearch_WhenUrlDoesNotExist_ShouldNotBeCached() {
AtomicInteger safeCounter = new AtomicInteger(0);
IntStream.range(0, 10).forEach(i -> safeCounter
.updateAndGet(value -> cachingService
.isSafeUrlByDirectSearch(SAFE_URL) ? value + 1 : value));
Assertions.assertEquals(safeCounter.get(), 10);
verify(repository, times(10)).existsByUrlsContains(SAFE_URL);
}

@Test
public void testGetDomainFromCache_WhenDomainExists_ShouldBeCached() {

AtomicInteger unsafeCounter = new AtomicInteger(0);
Domain domain = Domain.builder().name(UNSAFE_DOMAIN).urls(List.of(UNSAFE_URL)).build();
IntStream.range(0, 100).forEach(i -> unsafeCounter
.updateAndGet(value -> cachingService
.getDomainFromCache(UNSAFE_DOMAIN).equals(domain) ? value + 1 : value));
Assertions.assertEquals(unsafeCounter.get(), 100);
verify(repository, times(1)).findById(UNSAFE_DOMAIN);
}

@Test
public void testGetDomainFromCache_WhenDomainDoesNotExists_ShouldNotBeCached() {
AtomicInteger safeCounter = new AtomicInteger(0);
IntStream.range(0, 100).forEach(i -> safeCounter
.updateAndGet(value -> cachingService
.getDomainFromCache(SAFE_DOMAIN) == null ? value + 1 : value));
Assertions.assertEquals(safeCounter.get(), 100);
verify(repository, times(100)).findById(SAFE_DOMAIN);
}

@Test
public void testIsUnsafeDomainCached_WhenDomainExist_ShouldBeCached() {
AtomicInteger unsafeCounter = new AtomicInteger(0);
IntStream.range(0, 100).forEach(i -> unsafeCounter
.updateAndGet(value -> cachingService
.isUnsafeDomainCached(UNSAFE_DOMAIN) ? value + 1 : value));
Assertions.assertEquals(unsafeCounter.get(), 100);
verify(repository, times(1)).existsByName(UNSAFE_DOMAIN);
}

@Test
public void testIsUnsafeDomainCached_WhenDomainDoesNotExist_ShouldNotBeCached() {
AtomicInteger safeCounter = new AtomicInteger(0);
IntStream.range(0, 100).forEach(i -> safeCounter
.updateAndGet(value -> cachingService
.isUnsafeDomainCached(SAFE_DOMAIN) ? value : value + 1));
Assertions.assertEquals(safeCounter.get(), 100);
verify(repository, times(100)).existsByName(SAFE_DOMAIN);
}
}

In each of the tests, I created a counter to make sure each method call had the correct return value and then used the DomainRepository spy bean to assert that the return value was indeed cached, or not cached as demanded. for example, let's look at the first method:

@Test
public void testIsSafeUrlByDirectSearch_WhenUrlExist_ShouldBeCached() {
AtomicInteger unsafeCounter = new AtomicInteger(0);
IntStream.range(0, 100).forEach(i -> unsafeCounter
.updateAndGet(value -> cachingService
.isSafeUrlByDirectSearch(UNSAFE_URL) ? value : value + 1));
Assertions.assertEquals(unsafeCounter.get(), 100);
verify(repository, times(1)).existsByUrlsContains(UNSAFE_URL);
}

The unsafeCounter counts and asserts that the number of successful calls for the caching service isSafeUrlByDirectSearch method is exactly 100. Consequently, the last line uses Mokito verify to ensure that the return value was actually cached, by ensuring that the repository existsByUrlsContains method was only called once, the first time. Each return value after that originated from the cache with the spring AOP proxy and not a real method call.

Now, let’s look at the second method for an opposite example:

@Test
public void testIsSafeUrlByDirectSearch_WhenUrlDoesNotExist_ShouldNotBeCached() {
AtomicInteger safeCounter = new AtomicInteger(0);
IntStream.range(0, 10).forEach(i -> safeCounter
.updateAndGet(value -> cachingService
.isSafeUrlByDirectSearch(SAFE_URL) ? value + 1 : value));
Assertions.assertEquals(safeCounter.get(), 10);
verify(repository, times(10)).existsByUrlsContains(SAFE_URL);
}

In this test method, I wanted to make sure that the return value was not cached, so I used the same mechanism to ensure that the number of service isSafeUrlByDirectSearch calls is exactly the same as the repository existsByUrlsContains calls, which means — the return value was not cached, since the existsByUrlsContains had to be called each time.

Conclusion

Spring boot caching can be hard to debug, and harder to test, especially since the data is not visible. The use of Spring AOP proxies behind the scenes doesn’t make it easier.

in this article, I tried to shed some light on some of the basic components which together make up the design of the caching integration tests. We use SpringBootTest and SpyBean annotations and counted the repository method calls to know if the return value was cached or not.

Please click the 👏🏽 button if you find my article helpful, or comment below to talk about it.

The repo used for this project: link-validator-API.
You’re welcome to reach out on
LinkedIn.

--

--