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
GenericContainersetup 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.