Redis Testing with Testcontainers

Redis cache testing with a real Redis instance catches behaviors that embedded or mocked alternatives cannot: TTL expiry timing, cache eviction under memory pressure, cluster failover behavior, and the exact serialization format that Redis stores data in. This article covers testing Spring Cache, Spring Session, and distributed locks against a real Redis container.


What You’ll Learn

  • Redis GenericContainer setup and @ServiceConnection (Spring Boot 3.2+)
  • Testing Spring Cache with @Cacheable, @CacheEvict, and @CachePut
  • Verifying TTL expiry behavior
  • Testing Spring Session with Redis
  • Testing distributed locks with Redisson or Spring Integration

Dependencies

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

<!-- Test -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

GenericContainer Redis Setup

There is no dedicated RedisContainer module in Testcontainers core (as of version 1.20). Use GenericContainer with the official Redis image:

@SpringBootTest
@Testcontainers
class OrderCacheTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }
}

Spring Boot 3.2+ with @ServiceConnection

Spring Boot 3.2+ introduced @ServiceConnection support for Redis containers:

@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
    .withExposedPorts(6379)
    .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

Testing Spring Cache

The OrderService uses Spring Cache to cache order lookups:

@Service
@CacheConfig(cacheNames = "orders")
public class OrderService {

    private final OrderRepository orderRepository;
    private int dbCallCount = 0;  // for testing only

    @Cacheable(key = "#id")
    public Order findById(Long id) {
        dbCallCount++;
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @CachePut(key = "#result.id")
    public Order updateStatus(Long id, OrderStatus newStatus) {
        Order order = orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
        order.setStatus(newStatus);
        return orderRepository.save(order);
    }

    @CacheEvict(key = "#id")
    public void deleteOrder(Long id) {
        orderRepository.deleteById(id);
    }

    @CacheEvict(allEntries = true)
    public void clearCache() {}

    public int getDbCallCount() { return dbCallCount; }
    public void resetDbCallCount() { dbCallCount = 0; }
}
@SpringBootTest
@Testcontainers
class OrderCacheTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setup() {
        orderRepository.deleteAll();
        orderService.clearCache();
        orderService.resetDbCallCount();
    }

    @Test
    void shouldCacheOrderAfterFirstLookup() {
        Order order = orderRepository.save(
            new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(99.99))
        );

        // First call hits the database
        Order firstResult = orderService.findById(order.getId());
        assertThat(orderService.getDbCallCount()).isEqualTo(1);

        // Second call should hit the cache
        Order secondResult = orderService.findById(order.getId());
        assertThat(orderService.getDbCallCount()).isEqualTo(1);  // still 1 — no additional DB call

        assertThat(firstResult.getId()).isEqualTo(secondResult.getId());
    }

    @Test
    void shouldEvictCacheOnDelete() {
        Order order = orderRepository.save(
            new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(50.00))
        );

        // Warm the cache
        orderService.findById(order.getId());
        assertThat(orderService.getDbCallCount()).isEqualTo(1);

        // Delete evicts the cache
        orderService.deleteOrder(order.getId());

        // Next lookup should go to DB (and throw because order is deleted)
        assertThatThrownBy(() -> orderService.findById(order.getId()))
            .isInstanceOf(OrderNotFoundException.class);

        assertThat(orderService.getDbCallCount()).isEqualTo(2);  // hit DB again
    }

    @Test
    void shouldUpdateCacheOnStatusChange() {
        Order order = orderRepository.save(
            new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(75.00))
        );

        // Warm cache with PENDING status
        orderService.findById(order.getId());

        // Update via @CachePut — updates both DB and cache
        Order updated = orderService.updateStatus(order.getId(), OrderStatus.CONFIRMED);

        // Cache should return updated status without DB call
        Order fromCache = orderService.findById(order.getId());
        assertThat(fromCache.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        assertThat(orderService.getDbCallCount()).isEqualTo(1);  // still 1 — cache served the update
    }
}

Verifying Cache TTL

Test that cache entries expire after the configured TTL. Configure TTL in Spring Boot:

spring:
  cache:
    redis:
      time-to-live: 2s  # 2 seconds for testing
@Test
void shouldExpireCacheAfterTtl() throws InterruptedException {
    Order order = orderRepository.save(
        new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(99.99))
    );

    // Warm cache
    orderService.findById(order.getId());
    assertThat(orderService.getDbCallCount()).isEqualTo(1);

    // Wait for TTL to expire (2s TTL + buffer)
    Thread.sleep(3000);

    // Cache should be expired — next call hits DB
    orderService.findById(order.getId());
    assertThat(orderService.getDbCallCount()).isEqualTo(2);
}

Verifying Redis Key Format with StringRedisTemplate

Use StringRedisTemplate to inspect what Redis actually stores:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Test
void shouldStoreOrderInRedisWithExpectedKey() {
    Order order = orderRepository.save(
        new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(99.99))
    );

    orderService.findById(order.getId());

    // Check the Redis key exists
    String expectedKeyPattern = "orders::" + order.getId();
    Set<String> keys = stringRedisTemplate.keys("orders::*");

    assertThat(keys).isNotNull();
    assertThat(keys).anyMatch(k -> k.contains(order.getId().toString()));
}

Testing Spring Session with Redis

Spring Session stores HTTP sessions in Redis, enabling stateful session management across multiple application instances.

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class SessionManagementTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void shouldPersistSessionToRedis() {
        // Login to create a session
        ResponseEntity<String> loginResponse = restTemplate.postForEntity(
            "/login",
            new LoginRequest("user", "password"),
            String.class
        );

        assertThat(loginResponse.getStatusCode().is2xxSuccessful()).isTrue();

        String sessionCookie = loginResponse.getHeaders()
            .getFirst(HttpHeaders.SET_COOKIE);
        assertThat(sessionCookie).isNotNull();

        // Verify session was stored in Redis
        Set<String> sessionKeys = stringRedisTemplate.keys("spring:session:*");
        assertThat(sessionKeys).isNotNull();
        assertThat(sessionKeys).isNotEmpty();
    }
}

Common Pitfalls

No dedicated RedisContainer — using GenericContainer. Unlike PostgreSQL and MySQL, there is no RedisContainer class in Testcontainers core. Always use new GenericContainer<>("redis:7-alpine").withExposedPorts(6379).

Forgetting the wait strategy. GenericContainer defaults to Wait.forListeningPort(). Redis binds to port 6379 slightly before it accepts connections. Use Wait.forLogMessage(".*Ready to accept connections.*\\n", 1) for reliable readiness detection.

Cache state leaking between tests. Always clear the cache in @BeforeEach using either cacheManager.getCache("orders").clear() or a dedicated @CacheEvict(allEntries = true) method.


Summary

Redis containers with Testcontainers give you real TTL expiry, real serialization verification, and real connection pool behavior that mocked Redis cannot replicate. Testing Spring Cache, Spring Session, and distributed locks against a real Redis instance catches serialization mismatches, TTL configuration errors, and connection handling bugs before production.

The next article covers WireMock — testing REST API integrations by mocking external HTTP services.

Next: WireMock — Testing External REST APIs