Cache Invalidation Testing Strategies: Patterns and Anti-Patterns
Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. Testing cache invalidation is hard for different reasons—the behavior is time-dependent, state-dependent, and often involves distributed components that are difficult to simulate in isolation.
This guide covers the major cache invalidation patterns and how to test each one reliably.
Why Cache Invalidation Tests Fail
Most cache bugs aren't "cache miss when there should be a hit." They're:
- Stale data served — the cache wasn't invalidated when the underlying data changed
- Thundering herd — invalidation triggers simultaneous cache rebuilds from multiple consumers
- Partial invalidation — some cache keys were updated, others weren't (related data inconsistency)
- Race conditions — a write happens between a cache read and a subsequent write-back
Testing these requires controlling time (TTL), controlling order of operations (races), and observing what data was served—not just whether a function was called.
Pattern 1: TTL-Based Expiry
The simplest invalidation strategy: set a TTL, let data expire naturally.
Implementation
# user_service.py
import json
import time
import redis
r = redis.Redis.from_url('redis://localhost:6379', decode_responses=True)
CACHE_TTL = 300 # 5 minutes
def get_user(user_id: int) -> dict:
key = f'user:{user_id}'
cached = r.get(key)
if cached:
return json.loads(cached)
user = fetch_user_from_db(user_id) # DB call
r.setex(key, CACHE_TTL, json.dumps(user))
return user
def update_user(user_id: int, data: dict) -> dict:
user = update_user_in_db(user_id, data) # DB write
r.delete(f'user:{user_id}') # Explicit invalidation
return userTesting TTL Expiry
Testing real TTL expiry means either waiting (slow) or using fakeredis2 with time control:
# test_user_service_ttl.py
import fakeredis
import pytest
import time
from unittest.mock import patch, MagicMock
from freezegun import freeze_time
import user_service
@pytest.fixture
def fake_redis():
server = fakeredis.FakeServer()
client = fakeredis.FakeRedis(server=server, decode_responses=True)
return client
def test_cache_expires_after_ttl(fake_redis, monkeypatch):
monkeypatch.setattr(user_service, 'r', fake_redis)
db_call = MagicMock(return_value={'id': 1, 'name': 'Alice'})
monkeypatch.setattr(user_service, 'fetch_user_from_db', db_call)
# First call: cache miss, DB hit
user_service.get_user(1)
assert db_call.call_count == 1
# Within TTL: cache hit, no DB call
user_service.get_user(1)
assert db_call.call_count == 1
# Simulate TTL expiry by deleting key directly
fake_redis.delete('user:1')
# After expiry: cache miss again
user_service.get_user(1)
assert db_call.call_count == 2
def test_explicit_invalidation_on_update(fake_redis, monkeypatch):
monkeypatch.setattr(user_service, 'r', fake_redis)
db_fetch = MagicMock(side_effect=[
{'id': 1, 'name': 'Alice'}, # first fetch
{'id': 1, 'name': 'Alice V2'} # after update
])
db_update = MagicMock(return_value={'id': 1, 'name': 'Alice V2'})
monkeypatch.setattr(user_service, 'fetch_user_from_db', db_fetch)
monkeypatch.setattr(user_service, 'update_user_in_db', db_update)
# Warm the cache
user1 = user_service.get_user(1)
assert user1['name'] == 'Alice'
# Update should invalidate
user_service.update_user(1, {'name': 'Alice V2'})
# Next read should be fresh
user2 = user_service.get_user(1)
assert user2['name'] == 'Alice V2'
assert db_fetch.call_count == 2 # Two DB fetchesTesting with Real TTL via Testcontainers
For tests that must verify actual TTL behavior (e.g., checking that a 1-second TTL actually expires):
# test_real_ttl.py
import pytest
import redis
import time
from testcontainers.redis import RedisContainer
@pytest.fixture(scope='module')
def real_redis():
with RedisContainer('redis:7-alpine') as container:
client = redis.Redis(
host=container.get_container_host_ip(),
port=container.get_exposed_port(6379),
decode_responses=True
)
yield client
def test_actual_ttl_expiry(real_redis):
real_redis.setex('temp_key', 1, 'value') # 1-second TTL
assert real_redis.get('temp_key') == 'value'
time.sleep(1.1) # Wait for expiry
assert real_redis.get('temp_key') is None
def test_ttl_refreshed_on_access(real_redis):
# Some implementations refresh TTL on read (sliding expiry)
real_redis.setex('sliding_key', 2, 'value')
time.sleep(1)
real_redis.expire('sliding_key', 2) # Reset TTL
time.sleep(1)
# Should still exist (reset 1s before expiry)
assert real_redis.get('sliding_key') == 'value'Pattern 2: Event-Driven Invalidation
Update the cache when events occur. The source of truth changes → invalidate the corresponding cache entries.
Implementation
// event-driven-cache.js
const EventEmitter = require('events');
const redis = require('redis');
const events = new EventEmitter();
const client = redis.createClient();
// Cache invalidation subscriptions
events.on('user.updated', async ({ userId }) => {
await client.del(`user:${userId}`);
await client.del(`user:${userId}:profile`);
await client.del(`user:${userId}:permissions`);
});
events.on('order.placed', async ({ userId, orderId }) => {
await client.del(`user:${userId}:orders`);
await client.del(`order:${orderId}`);
// Invalidate aggregate cache
await client.del('stats:daily:orders');
});
module.exports = { events, client };Testing Event-Driven Invalidation
// event-driven-cache.test.js
const { events, client } = require('./event-driven-cache');
const redisMock = require('redis-mock');
jest.mock('redis', () => redisMock);
describe('Cache invalidation on events', () => {
beforeEach(async () => {
await client.flushAll();
});
test('user.updated invalidates all user cache keys', async () => {
// Seed cache
await client.set('user:42', JSON.stringify({ id: 42, name: 'Bob' }));
await client.set('user:42:profile', JSON.stringify({ bio: '...' }));
await client.set('user:42:permissions', JSON.stringify(['read']));
// Verify seeds
expect(await client.get('user:42')).not.toBeNull();
expect(await client.get('user:42:profile')).not.toBeNull();
// Emit event
events.emit('user.updated', { userId: 42 });
// Allow event handlers to complete
await new Promise(resolve => setImmediate(resolve));
// All keys invalidated
expect(await client.get('user:42')).toBeNull();
expect(await client.get('user:42:profile')).toBeNull();
expect(await client.get('user:42:permissions')).toBeNull();
});
test('user.updated does not affect other user cache', async () => {
await client.set('user:1', JSON.stringify({ id: 1 }));
await client.set('user:2', JSON.stringify({ id: 2 }));
events.emit('user.updated', { userId: 1 });
await new Promise(resolve => setImmediate(resolve));
expect(await client.get('user:1')).toBeNull();
expect(await client.get('user:2')).not.toBeNull(); // Unaffected
});
test('order.placed invalidates order and daily stats', async () => {
await client.set('user:5:orders', JSON.stringify([]));
await client.set('stats:daily:orders', '42');
events.emit('order.placed', { userId: 5, orderId: 'ord-123' });
await new Promise(resolve => setImmediate(resolve));
expect(await client.get('user:5:orders')).toBeNull();
expect(await client.get('stats:daily:orders')).toBeNull();
});
});Pattern 3: Write-Through Cache
Every write updates both the database and the cache atomically. No explicit invalidation needed—the cache always has fresh data.
Implementation
# write_through_cache.py
import json
import redis
import psycopg2
r = redis.Redis.from_url('redis://localhost')
def update_product_price(product_id: int, new_price: float) -> dict:
# Update DB first
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
'UPDATE products SET price = %s WHERE id = %s RETURNING *',
(new_price, product_id)
)
product = dict(zip([d[0] for d in cur.description], cur.fetchone()))
conn.commit()
# Write through to cache
r.setex(
f'product:{product_id}',
3600,
json.dumps(product)
)
return product
def get_product(product_id: int) -> dict | None:
cached = r.get(f'product:{product_id}')
if cached:
return json.loads(cached)
# Cache miss — load from DB
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute('SELECT * FROM products WHERE id = %s', (product_id,))
row = cur.fetchone()
if not row:
return None
product = dict(zip([d[0] for d in cur.description], row))
r.setex(f'product:{product_id}', 3600, json.dumps(product))
return productTesting Write-Through Consistency
# test_write_through.py
import fakeredis
import pytest
from unittest.mock import patch, MagicMock, call
import write_through_cache
@pytest.fixture
def setup(monkeypatch):
fake_r = fakeredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(write_through_cache, 'r', fake_r)
return fake_r
def test_update_writes_to_cache(setup, monkeypatch):
fake_r = setup
mock_product = {'id': 1, 'name': 'Widget', 'price': 9.99}
mock_conn = MagicMock()
mock_cur = mock_conn.__enter__().cursor().__enter__()
mock_cur.description = [('id',), ('name',), ('price',)]
mock_cur.fetchone.return_value = (1, 'Widget', 9.99)
monkeypatch.setattr(write_through_cache, 'get_db_connection', lambda: mock_conn)
result = write_through_cache.update_product_price(1, 9.99)
# Verify cache was written
cached = fake_r.get('product:1')
assert cached is not None
import json
assert json.loads(cached)['price'] == 9.99
def test_get_uses_cache_after_update(setup, monkeypatch):
fake_r = setup
mock_conn = MagicMock()
mock_cur = mock_conn.__enter__().cursor().__enter__()
mock_cur.description = [('id',), ('name',), ('price',)]
mock_cur.fetchone.return_value = (1, 'Widget', 19.99)
monkeypatch.setattr(write_through_cache, 'get_db_connection', lambda: mock_conn)
# Update writes to cache
write_through_cache.update_product_price(1, 19.99)
# Reset mock to detect if DB is called again
mock_cur.fetchone.reset_mock()
# Next read should come from cache, not DB
product = write_through_cache.get_product(1)
assert product['price'] == 19.99
mock_cur.fetchone.assert_not_called()Pattern 4: Cache-Aside with Stampede Protection
When many requests hit a cold cache simultaneously, all might trigger DB fetches. Test that stampede protection works.
Implementation
# stampede_protected_cache.py
import json
import time
import redis
import threading
r = redis.Redis.from_url('redis://localhost', decode_responses=True)
LOCK_TIMEOUT = 5
def get_with_lock(key: str, fetch_fn, ttl: int = 300):
cached = r.get(key)
if cached:
return json.loads(cached)
lock_key = f'lock:{key}'
lock = r.set(lock_key, '1', nx=True, ex=LOCK_TIMEOUT)
if lock:
# We got the lock — fetch and populate
try:
value = fetch_fn()
r.setex(key, ttl, json.dumps(value))
return value
finally:
r.delete(lock_key)
else:
# Another process is fetching — wait and retry
deadline = time.time() + LOCK_TIMEOUT
while time.time() < deadline:
time.sleep(0.05)
cached = r.get(key)
if cached:
return json.loads(cached)
# Fallback: fetch directly if lock holder died
return fetch_fn()Testing Stampede Protection
# test_stampede_protection.py
import threading
import pytest
import fakeredis
from unittest.mock import MagicMock
import stampede_protected_cache
def test_only_one_fetch_under_concurrent_load(monkeypatch):
fake_r = fakeredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(stampede_protected_cache, 'r', fake_r)
call_count = 0
event = threading.Event()
def slow_fetch():
nonlocal call_count
call_count += 1
event.wait(timeout=0.1) # Simulate slow DB query
return {'data': 'expensive_result'}
results = []
errors = []
def make_request():
try:
result = stampede_protected_cache.get_with_lock('expensive_key', slow_fetch)
results.append(result)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=make_request) for _ in range(10)]
for t in threads:
t.start()
event.set() # Allow fetch to complete
for t in threads:
t.join(timeout=10)
assert len(errors) == 0
assert len(results) == 10
assert all(r == {'data': 'expensive_result'} for r in results)
# Only 1 actual DB fetch despite 10 concurrent requests
assert call_count == 1Pattern 5: Batch Invalidation with Key Patterns
Invalidating all keys matching a pattern (e.g., all cache entries for a given tenant).
Implementation
# batch_invalidation.py
import redis
r = redis.Redis.from_url('redis://localhost')
def invalidate_tenant_cache(tenant_id: str):
"""Invalidate all cache keys for a tenant."""
pattern = f'tenant:{tenant_id}:*'
cursor = 0
deleted = 0
while True:
cursor, keys = r.scan(cursor=cursor, match=pattern, count=100)
if keys:
r.delete(*keys)
deleted += len(keys)
if cursor == 0:
break
return deletedTesting Batch Invalidation
# test_batch_invalidation.py
import fakeredis
import pytest
import batch_invalidation
def test_invalidates_all_tenant_keys(monkeypatch):
fake_r = fakeredis.FakeRedis()
monkeypatch.setattr(batch_invalidation, 'r', fake_r)
# Seed keys for tenant A
for i in range(50):
fake_r.set(f'tenant:A:user:{i}', f'data_{i}')
fake_r.set(f'tenant:A:order:{i}', f'order_{i}')
# Seed keys for tenant B (should not be affected)
for i in range(10):
fake_r.set(f'tenant:B:user:{i}', f'data_{i}')
deleted = batch_invalidation.invalidate_tenant_cache('A')
assert deleted == 100 # 50 users + 50 orders
# All tenant A keys gone
assert fake_r.keys('tenant:A:*') == []
# Tenant B keys intact
assert len(fake_r.keys('tenant:B:*')) == 10
def test_returns_zero_for_nonexistent_tenant(monkeypatch):
fake_r = fakeredis.FakeRedis()
monkeypatch.setattr(batch_invalidation, 'r', fake_r)
deleted = batch_invalidation.invalidate_tenant_cache('nonexistent')
assert deleted == 0Testing Anti-Patterns to Avoid
Anti-Pattern 1: Asserting calls instead of state
# BAD: tests that invalidation was called, not that cache is actually empty
def test_bad_invalidation():
with patch.object(redis_client, 'delete') as mock_delete:
update_user(1, {'name': 'New'})
mock_delete.assert_called_once_with('user:1')
# This passes even if delete() is a no-op that doesn't work
# GOOD: test the actual state
def test_good_invalidation(fake_redis):
fake_redis.set('user:1', json.dumps({'id': 1, 'name': 'Old'}))
update_user(1, {'name': 'New'})
assert fake_redis.get('user:1') is None # Cache is actually emptyAnti-Pattern 2: Not testing the stale data case
Most cache tests only test that invalidation happens after an update. They don't test that stale data is served if invalidation is missing. Always add a regression test:
def test_stale_data_not_served_after_update(fake_redis):
# Warm cache with old data
fake_redis.setex('user:1', 3600, json.dumps({'name': 'Old Name'}))
# Update in DB
update_user_in_db(1, {'name': 'New Name'})
# Without explicit cache invalidation, this would return stale data
# This test documents that the invalidation MUST happen
result = get_user(1)
assert result['name'] == 'New Name', "Cache was not invalidated after DB update"Anti-Pattern 3: Time.sleep in tests
Never sleep to wait for TTL expiry in tests. It makes tests slow and flaky. Use fakeredis2 with time control or delete keys directly to simulate expiry.
Conclusion
Cache invalidation tests must verify state, not method calls. Use fakeredis/redis-mock for fast unit tests of invalidation logic, Testcontainers when you need real Redis behavior. Always test the stale data case—what happens if invalidation fails—not just the happy path where it succeeds.