1. Overview

In addition to implementations, we can use Spring’s declarative caching mechanism to annotate interfaces. For instance, we can declare caching on a Spring Data repository.

In this tutorial, we’re going to show how to test such a scenario.

2. Getting Started

First, let’s create a simple model:

@Entity
public class Book {

    @Id
    private UUID id;
    private String title;

}

And then, let’s add a repository interface that has a @Cacheable method:

public interface BookRepository extends CrudRepository<Book, UUID> {

    @Cacheable(value = "books", unless = "#a0=='Foundation'")
    Optional<Book> findFirstByTitle(String title);

}

The unless condition here is not mandatory. It will just help us test some cache-miss scenarios in a moment.

Also, note the SpEL expression “#a0” instead of the more readable “#title”. We do this because the proxy won’t keep the parameter names. So, we use the alternative #root.arg[0], p0 or a0 notation.

3. Testing

The goal of our tests is to make sure the caching mechanism works. Therefore, we don’t intend to cover the Spring Data repository implementation or the persistence aspects.

3.1. Spring Boot

Let’s start with a simple Spring Boot test.

First, we’ll set up our test dependencies, add some test data, and create a simple utility method to check whether a book is in the cache or not:

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = CacheApplication.class)
public class BookRepositoryIntegrationTest {

    @Autowired
    CacheManager cacheManager;

    @Autowired
    BookRepository repository;

    @BeforeEach
    void setUp() {
        repository.save(new Book(UUID.randomUUID(), "Dune"));
        repository.save(new Book(UUID.randomUUID(), "Foundation"));
    }

    private Optional<Book> getCachedBook(String title) {
        return ofNullable(cacheManager.getCache("books")).map(c -> c.get(title, Book.class));
    }

Now, let’s make sure that after requesting a book, it gets placed in the cache:

    @Test
    void givenBookThatShouldBeCached_whenFindByTitle_thenResultShouldBePutInCache() {
        Optional<Book> dune = repository.findFirstByTitle("Dune");

        assertEquals(dune, getCachedBook("Dune"));
    }

And also, that some books are not placed in the cache:

    @Test
    void givenBookThatShouldNotBeCached_whenFindByTitle_thenResultShouldNotBePutInCache() {
        repository.findFirstByTitle("Foundation");

        assertEquals(empty(), getCachedBook("Foundation"));
    }

In this test, we make use of the Spring-provided CacheManager and check that after each repository.findFirstByTitle operation, the CacheManager contains (or does not contain) books according to the @Cacheable rules.

3.2. Plain Spring

Let’s now continue with a Spring integration test. And for a change, this time let’s mock our interface. Then we’ll verify interactions with it in different test cases.

We’ll start by creating a @Configuration that provides the mock implementation for our BookRepository:

@ContextConfiguration
@ExtendWith(SpringExtension.class)
public class BookRepositoryCachingIntegrationTest {

    private static final Book DUNE = new Book(UUID.randomUUID(), "Dune");
    private static final Book FOUNDATION = new Book(UUID.randomUUID(), "Foundation");

    private BookRepository mock;

    @Autowired
    private BookRepository bookRepository;

    @EnableCaching
    @Configuration
    public static class CachingTestConfig {

        @Bean
        public BookRepository bookRepositoryMockImplementation() {
            return mock(BookRepository.class);
        }

        @Bean
        public CacheManager cacheManager() {
            return new ConcurrentMapCacheManager("books");
        }

    }

Before moving on to setting up our mock’s behavior, there are two aspects worth mentioning about successfully using Mockito in this context:

  • BookRepository is a proxy around our mock. So, in order to use Mockito validations, we retrieve the actual mock via AopTestUtils.getTargetObject
  • We make sure to reset(mock) in between tests because CachingTestConfig loads only once
    @BeforeEach
    void setUp() {
        mock = AopTestUtils.getTargetObject(bookRepository);

        reset(mock);

        when(mock.findFirstByTitle(eq("Foundation")))
                .thenReturn(of(FOUNDATION));

        when(mock.findFirstByTitle(eq("Dune")))
                .thenReturn(of(DUNE))
                .thenThrow(new RuntimeException("Book should be cached!"));
    }

Now, we can add our test methods. We’ll start by making sure that after a book is placed in the cache, there are no more interactions with the repository implementation when later trying to retrieve that book:

    @Test
    void givenCachedBook_whenFindByTitle_thenRepositoryShouldNotBeHit() {
        assertEquals(of(DUNE), bookRepository.findFirstByTitle("Dune"));
        verify(mock).findFirstByTitle("Dune");

        assertEquals(of(DUNE), bookRepository.findFirstByTitle("Dune"));
        assertEquals(of(DUNE), bookRepository.findFirstByTitle("Dune"));

        verifyNoMoreInteractions(mock);
    }

And we also want to check that for non-cached books, we invoke the repository every time:

    @Test
    void givenNotCachedBook_whenFindByTitle_thenRepositoryShouldBeHit() {
        assertEquals(of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));
        assertEquals(of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));
        assertEquals(of(FOUNDATION), bookRepository.findFirstByTitle("Foundation"));

        verify(mock, times(3)).findFirstByTitle("Foundation");
    }

4. Summary

To sum it up, we used Spring, Mockito, and Spring Boot to implement a series of integration tests that make sure the caching mechanism applied to our interface works properly.

Note that we could also combine the approaches above. For example, nothing stops us from using mocks with Spring Boot or from performing checks on the CacheManager in the plain Spring test.

The complete code is available over on GitHub.