DEV Community

Muralidharan Lakshmanan
Muralidharan Lakshmanan

Posted on

Testing Your Cache: Proving It Actually Works

Part 9 built a Cache-Aside implementation with Spring Boot and Redis: TTL, structured keys, serialization, invalidation, database fallback. At that point it's tempting to declare victory — "the cache works." But what does "works" actually mean? Does it work when Redis is empty, or unavailable, or when a thousand requests land at the same instant, or when a cached value expires while the database keeps moving underneath it? And underneath all of that: did the cache actually make anything faster, or does it just look like it should have?

That's what this post tests.


1. Don't just test the happy path

The test almost everyone writes first is Request → Redis → HIT → success. That's useful, but it's one of three paths a cache actually has in production.

A cache has three paths to test: a request checks Redis and can hit, returning the cached value; miss, falling through to the database and repopulating Redis; or error, taking the fallback path — the HIT path is what most test suites cover, but the ERROR path is the one production actually needs

The HIT path is the one every test suite has. The ERROR path — Redis throwing a connection exception mid-request — is the one that's usually missing, and it's the one that determines whether an incident stays contained or spreads.


2. Test scenarios worth defining upfront

For a product API, a reasonably complete list looks like: cache hit (don't call the database), cache miss (call the database and populate the cache), TTL expiration (fetch fresh data once the entry ages out), an update (invalidate the cache), Redis unavailable (fall back appropriately), concurrent requests (avoid redundant database load), database unavailable (fail predictably), a serialization mismatch (fail safely), and a direct performance comparison (cache should measurably reduce latency). Naming these up front turns "test the cache" into something concrete enough to actually write.


3. Test #1 — cache hit

Suppose Redis already has product:101 cached, and a request comes in for it. The database should never be touched.

@Test
void shouldReturnProductFromCache() {

    Product product =
        new Product(101L, "MacBook Pro", 1999);

    when(redisTemplate.opsForValue()
        .get("product:101"))
        .thenReturn(product);

    Product result =
        productService.getProduct(101L);

    assertEquals("MacBook Pro", result.getName());

    verify(productRepository, never())
        .findById(101L);
}
Enter fullscreen mode Exit fullscreen mode

The assertion that matters most here isn't assertEquals — it's verify(repository, never()). That's the line proving the database wasn't touched, not just that the right value came back.


4. Why that second assertion matters

It's possible for a cache to be returning correct data while still quietly querying the database on every request — Redis HIT → database query anyway → return. The application looks like it works, the response is correct, and none of that tells you the cache isn't providing its actual benefit. A real cache-hit test checks two separate things: correctness (the value is right) and efficiency (the database wasn't touched to get it). Either one alone can pass while the other silently fails.


5. Test #2 — cache miss

Now Redis returns null. The application needs to query the database, return the result, and populate Redis — the full Cache-Aside loop from Part 4:

@Test
void shouldLoadFromDatabaseOnCacheMiss() {

    Product product =
        new Product(101L, "MacBook Pro", 1999);

    when(redisTemplate.opsForValue()
        .get("product:101"))
        .thenReturn(null);

    when(productRepository.findById(101L))
        .thenReturn(Optional.of(product));

    Product result =
        productService.getProduct(101L);

    assertEquals(101L, result.getId());

    verify(productRepository)
        .findById(101L);

    verify(redisTemplate.opsForValue())
        .set(
            eq("product:101"),
            eq(product),
            any(Duration.class)
        );
}
Enter fullscreen mode Exit fullscreen mode

This one verifies the whole loop, not just the read.


6. Test #3 — TTL

TTL is easy to get right in code and wrong in configuration, so it's worth verifying explicitly rather than assuming. For a unit test, you don't need to wait ten minutes — just confirm the cache write was configured with the TTL you expect:

verify(redisTemplate.opsForValue())
    .set(
        eq("product:101"),
        eq(product),
        eq(Duration.ofMinutes(10))
    );
Enter fullscreen mode Exit fullscreen mode

For an integration test, a short TTL — two seconds — lets you actually wait for expiration and confirm the entry disappears when it should.


7. Unit tests and integration tests catch different bugs

A unit test mocks Redis; an integration test runs against a real instance and exercises real serialization, real TTL behavior, real key formatting, and a real connection. Integration tests catch classes of bugs mocks structurally can't — misconfigured connection settings, a serialization format that doesn't round-trip the way you assumed, a TTL that isn't actually being applied. Both layers earn their place; neither substitutes for the other.


8. Test #4 — cache invalidation

Suppose Redis has product:101 at $1,999, and an update changes the database price to $1,899. The application should delete the cache entry:

@Test
void shouldInvalidateCacheAfterUpdate() {

    Product product =
        new Product(101L, "MacBook Pro", 1899);

    when(productRepository.save(product))
        .thenReturn(product);

    productService.updateProduct(product);

    verify(productRepository)
        .save(product);

    verify(redisTemplate)
        .delete("product:101");
}
Enter fullscreen mode Exit fullscreen mode

This test exists specifically to catch the most common caching bug there is: the database changed, and the cache didn't hear about it.


9. Test #5 — Redis is unavailable

Simulate the failure directly and confirm the application falls back to the database rather than failing the request outright:

when(redisTemplate.opsForValue()
    .get("product:101"))
    .thenThrow(new RedisConnectionFailureException(
        "Redis unavailable"
    ));
Enter fullscreen mode Exit fullscreen mode

then verify productRepository.findById(101L) gets called. This is the try/catch fallback from Part 9, and this test is what proves it's actually wired up.


10. But be careful what this test does and doesn't prove

If Redis is down and the application is taking 10,000 requests/sec, every one of them now falls through to the database — and this test will still pass, because "fallback works" was never in question. Whether the database survives 10,000 requests/sec it wasn't sized for is a completely different question, and this test doesn't answer it. Functional correctness and resilience are separate properties. A unit test proves the fallback path exists; only a load test proves the system survives actually using it — which is exactly what Part 6 and Part 7 covered from the failure-mode side.


11. Test #6 — concurrent requests

This is arguably the single most valuable test in the whole suite. If a popular key expires and a thousand requests arrive at nearly the same moment, unprotected Cache-Aside turns that into a thousand simultaneous database queries — the cache stampede from Part 7. With request coalescing in place, it should look closer to one request loading the database and the other 999 reading the result once it lands.


12. How to actually test that

Fire concurrent calls from a thread pool:

ExecutorService executor =
    Executors.newFixedThreadPool(20);

List<Callable<Product>> tasks =
    IntStream.range(0, 100)
        .mapToObj(i ->
            () -> productService.getProduct(101L))
        .toList();
Enter fullscreen mode Exit fullscreen mode

Execute them together, then check how many times the database was actually called. With coalescing working, 100 concurrent application requests should collapse into something close to a single database call — not exactly one, depending on timing and the coalescing strategy, but dramatically fewer than 100. This is a test an ordinary unit test simply cannot catch, because the bug only exists under concurrency.


13. Test #7 — database failure

Flip the failure around: Redis misses cleanly, but the database itself is unavailable. The API shouldn't return a bare 500 with no useful information — depending on the application, something like a 503 with enough context to be debuggable later is the better shape. The property that actually matters here isn't the exact status code; it's that the failure is predictable, observable, and bounded rather than an unhandled exception bubbling up as a mystery.


14. Test #8 — serialization

Suppose the cached bytes for product:101 don't match what the current application expects — an old serialized shape sitting next to new deserialization code, the exact versioning problem from Part 9. Deserialization can fail outright here, and a good integration test verifies the application doesn't just crash on it. A reasonable strategy: catch the deserialization failure, delete the bad entry, fall through to the database, and repopulate the cache correctly. That turns a corrupted cache entry into a self-healing miss instead of an application error.


15. Test #9 — measure before and after

The question that actually matters outside the engineering team: did this caching work improve anything? "Redis is fast" isn't evidence. Real numbers are — average latency, P95 latency, and database CPU, measured with caching off and then on, under the same load.

Prove it with numbers, not

Caching isn't valuable because Redis looks good on an architecture diagram — it's valuable because these specific numbers moved, on this specific workload.


16. What to actually measure

Two scenarios to benchmark: without the cache (application straight through to the database) and with it (application through Redis, database only on a miss). Track throughput, average latency, P95, P99, database queries per second, CPU, memory, and network traffic — not just the average, which brings us to the next point.


17. Why P95 and P99 matter more than the average

A 20ms average latency sounds great in isolation. If P95 is 200ms and P99 is 2 seconds, a meaningful slice of real users are having a genuinely bad experience that the average is quietly hiding. For production systems, tail latency is usually the number that determines whether users notice a problem — a caching benchmark that only reports the average is reporting the least informative number available.


18. A simple load test

Tools like JMeter, Gatling, or k6 can drive traffic — say, 1,000 virtual users hitting GET /products/101 for ten minutes, run once with caching disabled and once enabled:

No cache With cache
Throughput 5K/sec 20K/sec
P95 350 ms 45 ms
DB CPU 85% 30%
DB queries/sec 5K/sec 200/sec

The exact numbers depend entirely on your application and environment — what matters is running this methodology against your own workload, not adopting someone else's numbers as a benchmark for your system.


19. Test the worst case, not just the best one

Testing cache hit, cache hit, cache hit, cache hit will always look fantastic — of course it does, that's the easy path. The tests that actually tell you something: an empty cache, expiration under load, a Redis restart, Redis being fully unavailable, a hot key, a slow database, a traffic spike, concurrent requests racing each other. Production doesn't grade on the good day. It grades on the bad one.


20. Test cache recovery

Redis restarts, and every cached entry is gone at once. The application needs to survive the climb back from MISS → database → Redis SET → HIT repeating across a cold cache under live traffic. Worth measuring explicitly: how fast the cache repopulates, how much database load the recovery window generates, what application latency looks like during that window, and whether a stampede happens on the way back up. A cache restart is a routine operational event — it shouldn't be able to take the application down with it.


21. Test a hot key deliberately

Manufacture the scenario from Part 7 on purpose: send a large share of traffic — a million requests, say — at a single key like product:popular, and watch Redis CPU, Redis network, application latency, and database traffic while it happens. This test earns its keep specifically for applications with popular products, trending content, homepage data, shared configuration, or anything else likely to concentrate traffic on one key.


22. Testing isn't only about code

A cache can pass every unit test in the suite and still fail in production, because production has things a test environment doesn't fully replicate: real traffic patterns, real concurrency, real network latency, real failure timing, real data shapes, real deployment behavior. That gap is exactly why a single layer of testing was never going to be enough.


23. Four levels of cache testing

Four levels of cache testing: unit tests cover cache hit, miss, invalidation, and error handling and should be the most numerous; integration tests exercise real Redis, serialization, TTL, and key formatting; load tests cover concurrency and hot keys; failure tests cover Redis and database outages and should be the fewest, since they're the most expensive to run

Unit tests: cache hit, cache miss, invalidation, error handling — fast, isolated, and there should be a lot of them. Integration tests: Spring Boot against a real Redis, real serialization, real TTL and key behavior. Load tests: thousands of requests, concurrent users, hot keys, sustained misses — this is where you learn how the system behaves under pressure rather than in isolation. Failure tests: Redis unavailable, Redis restarting, a slow database, a database that's fully down, network faults — this is where you learn whether the architecture is actually resilient or just untested.


24. What to still monitor in production

Testing builds confidence before release; production observability is what tells you whether that confidence held up. At minimum: cache hit ratio, miss ratio, cache latency, Redis memory and CPU, evictions, connections, errors, hot keys, database fallback traffic, and application latency — and critically, correlated with each other rather than watched in isolation. A hit ratio drop that's followed by rising database traffic, rising database CPU, and rising application P95 tells a complete story that no single metric tells on its own.


25. The metric that matters more than hit ratio

99.9% is a great hit ratio, but it isn't the whole story: at 10,000 requests per second, 99.9% still means 10 misses per second, and if each of those misses costs 2 seconds, that small slice of traffic is enough on its own to dominate the P99

A 99.9% hit ratio at 10,000 requests/sec still means 10 requests/sec are hitting the database — and if each of those misses is a genuinely expensive 2-second query, that 0.1% is not a rounding error, it's very possibly your entire P99 problem. The question worth asking alongside "what's our hit ratio" is "what does a miss actually cost us" — the two questions together tell you something neither one tells you alone.


26. What "the cache works" should actually mean

Put together, a production-ready cache should demonstrate correctness (the right data comes back), performance (hits are measurably faster than the database path), efficiency (database traffic is genuinely reduced), resilience (behavior stays predictable when Redis fails), scalability (concurrency and traffic spikes don't cause a stampede), recoverability (the cache can rebuild itself after a failure), and observability (you can see when it's helping, and when it's quietly not). That's a considerably more useful bar than "the demo worked."


The bigger lesson

Testing a cache was never really about proving SET key value works — Redis already knows how to do that. It's about proving that Application → Cache → Database behaves correctly across everything working, something going wrong, traffic increasing, and data changing, all at once and in combination. That's the actual difference between code that works and a system that can survive contact with production.


What's the test in your suite that actually caught a real caching bug before it shipped — was it a unit test, or did it take a load test or a chaos experiment to surface?

Top comments (0)