At 2:17 AM, my phone exploded. Alert after alert: RDS CPU 100%, connection pool maxed out, users seeing 502 errors. Diving into the logs, I found the same user profile endpoint hammering the database with nearly a thousand queries per second—the cache was gone, and all those requests slammed straight into the DB, choking the connection pool. It wasn’t the first time. It was the third cache-consistency avalanche that month. After a long silence on the call with my teammates, someone finally voiced what we’d all been thinking: “Is our caching logic actually reliable? Every deploy feels like a gamble.”
We couldn’t afford to gamble anymore. We had to validate. We decided to build an automated test suite with Pytest + Docker, laser-focused on that “memory store.” The goal: catch every cache-consistency flaw in CI before it ever sniffs production.
Breaking down the problem: where cache inconsistency actually comes from
On the surface, caching is three simple operations: store, read, invalidate. But in real-world code, traps are everywhere:
-
Silent deletion failures after DB updates: A try/except silently swallows the
redis.delete()exception. You never even know it failed. - Read-after-write in concurrent scenarios: Thread A updates the database but hasn’t deleted the cache yet; thread B reads the stale cache and serves it.
- Key drift: A refactor changes the cache key template, but old keys linger in Redis forever with no expiration.
-
Serialization mismatches: Write with
pickle, read withjson.loads—boom, a deserialization error causes complete cache bypass. -
Cache penetration lurking: Malicious requests for non-existent
user_idhit the DB directly. You thought the Bloom filter would save you, but its false-positive rate silently drifted off the rails.
The usual approach is “write careful code + strict review + observe in production.” That relies on humans—who get tired, make mistakes, and get crushed by urgent deadlines. Mock tests never touch the real behaviors of Redis: network latency, serialization, blocking commands. It’s like learning to drift in a parking lot—you’ll crash the moment you hit a real road.
What we needed was a real environment + automated assertions: before merging code, make the caching logic fight a real Redis instance. Only a win counts as a pass.
Design: why Pytest + Docker instead of unit-test mocks
We explored a few paths at first:
-
fakeredis in memory: easy, but it doesn’t fully implement
expire, let alone distributed locks or key eviction policies. - Local Redis install: everyone’s dev environment differs—some on Redis 6, some on 7, plus the macOS compilation headaches.
- The reusable answer: Docker Compose + Pytest integration tests.
A docker-compose.yml declares a consistent Redis 7 service. Pytest uses a session-scoped fixture to bring up the container, wait for the port to be ready, and give each test an isolated, clean Redis database (via different DB numbers or flushdb cleanup). Why not use plugins like pytest-redis? Many wrap Docker too heavily, making debugging painful; managing the lifecycle ourselves gives us total transparency. In CI, it’s dead simple: docker-compose up -d then run the tests.
Our test cases cover: cache reads/writes, invalidation-on-update, concurrent read/write, Bloom filter protection against penetration, and extreme data serialization. Every test is a true interaction with Redis.
Core implementation: building the test rig step by step
1. Spin up a Redis container for your tests
First, write a docker-compose.yml that defines a redis service exposing port 6379, with --appendonly no to avoid persistence noise—we want speed, not RDB/AOF.
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "no", "--save", ""]
The fixture below solves the question: “How do I manage the Docker lifecycle in Pytest and guarantee Redis is ready before tests start?” It calls docker-compose via subprocess, polls a socket until the connection succeeds, then stops the container after tests finish.
# tests/conftest.py
import subprocess
import time
import socket
import pytest
import redis
def _redis_ready(host: str, port: int, timeout: int = 30) -> bool:
"""Poll until Redis accepts connections"""
start = time.time()
while time.time() - start < timeout:
try:
sock = socket.create_connection((host, port), timeout=1)
sock.close()
return True
except (ConnectionRefusedError, OSError):
time.sleep(0.5)
return False
@pytest.fixture(scope="session")
def docker_redis():
"""Session-scoped fixture: start docker-compose, stop when done"""
subprocess.run(
["docker-compose", "-f", "docker-compose.yml", "up", "-d"],
check=True,
)
if not _redis_ready("localhost", 6379):
subprocess.run(["docker-compose", "down"])
pytest.exit("Redis container startup timed out", returncode=1)
yield # tests execute
subprocess.run(["docker-compose", "-f", "docker-compose.yml", "down"])
@pytest.fixture
def redis_client(docker_redis):
"""Each test gets an isolated Redis connection; DB 0 flushed before use"""
client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
client.flushdb()
yield client
client.close()
decode_responses=True ensures that responses are strings, not bytes, keeping the test assertions straightforward. If your production code deals with binary data, you can toggle this off and handle decoding in the tests.
At this point, run pytest and you’ll see the container spin up before the first test and tear down after the last one. Every test begins with a clean Redis.
2. Translate real-world cache logic into testable cases
Here’s a typical caching pattern for a user profile: look in Redis first; on a miss, query the DB and write back. The real danger lies in what happens when the update and invalidation don’t align.
# app/cache.py (simplified version of the production logic)
import json
from typing import Optional, Dict
def get_user_profile(redis_client, db_session, user_id: str) -> Optional[Dict]:
key = f"user:{user_id}:profile"
cached = redis_client.get(key)
if cached:
return json.loads(cached)
# cache miss – fetch from DB
profile = db_session.query(User).filter_by(id=user_id).first()
if profile:
data = {"name": profile.name, "email": profile.email}
redis_client.setex(key, 300, json.dumps(data))
return data
return None
def update_user_profile(redis_client, db_session, user_id: str, new_data: Dict):
db_session.query(User).filter_by(id=user_id).update(new_data)
db_session.commit()
# crucially, invalidate the cache
try:
redis_client.delete(f"user:{user_id}:profile")
except redis.ConnectionError:
# original code swallowed this error – we need to catch it in tests
pass
Right there—the try/except silently eating redis.ConnectionError is exactly the kind of bug that melts production at 2 AM. Our tests must detect it.
3. Write the tests that would have caught the last three incidents
Test 1: Cache miss reads from DB and populates Redis
def test_cache_miss_populates_redis(redis_client, db_session):
user_id = "123"
# ensure user exists in DB using a factory or fixture
assert redis_client.get(f"user:{user_id}:profile") is None
profile = get_user_profile(redis_client, db_session, user_id)
assert profile is not None
assert redis_client.exists(f"user:{user_id}:profile") == 1
Test 2: Update invalidates the cache and the next read fetches fresh data
def test_update_deletes_cache_and_reflects_new_data(redis_client, db_session):
user_id = "456"
# seed cache with old data
old_data = json.dumps({"name": "Old Name", "email": "old@example.com"})
redis_client.setex(f"user:{user_id}:profile", 300, old_data)
new_data = {"name": "New Name", "email": "new@example.com"}
update_user_profile(redis_client, db_session, user_id, new_data)
# cache key should be gone
assert redis_client.exists(f"user:{user_id}:profile") == 0
# subsequent read gets the new data
profile = get_user_profile(redis_client, db_session, user_id)
assert profile["name"] == "New Name"
Test 3: Detection of swallowed deletion errors
To expose the silent try/except, we simulate a connection failure during deletion. We temporarily patch the redis_client.delete to raise an error and verify that the app doesn’t hide it.
from unittest.mock import patch
def test_update_propagation_failure_is_not_silent(redis_client, db_session):
user_id = "789"
# prime cache
redis_client.setex(f"user:{user_id}:profile", 300, "{}")
new_data = {"name": "X", "email": "x@x.com"}
# simulate deletion failure
with patch.object(redis_client, "delete", side_effect=redis.ConnectionError("boom")):
with pytest.raises(redis.ConnectionError):
# This test fails with the current buggy code – which is the point!
# After we remove the try/except, it passes.
update_user_profile(redis_client, db_session, user_id, new_data)
# cache should NOT be deleted (simulated failure), test verifies we notice
assert redis_client.exists(f"user:{user_id}:profile") == 1
This test is the antibody that hunts the exact bug that caused our 2 AM nightmare. In the original codebase, this test fails, immediately telling the developer: “You’re hiding a Redis failure, fix that.”
Test 4: Concurrent read/write scenario
import threading
import time
def test_concurrent_update_and_read(redis_client, db_session):
user_id = "concurr"
barrier = threading.Barrier(2)
def updater():
barrier.wait()
update_user_profile(redis_client, db_session, user_id, {"name": "Updater", "email": "up@d.com"})
def reader():
redis_client.setex(f"user:{user_id}:profile", 300, json.dumps({"name": "Stale", "email": "s@t.com"}))
barrier.wait()
time.sleep(0.05) # attempt to let updater finish DB write but before cache deletion
return get_user_profile(redis_client, db_session, user_id)
threads = [threading.Thread(target=updater), threading.Thread(target=reader)]
for t in threads:
t.start()
for t in threads:
t.join()
# After everything settles, subsequent reads must be consistent
final = get_user_profile(redis_client, db_session, user_id)
assert final["name"] == "Updater"
Note: Real concurrency tests are inherently flaky, but this pattern—combined with repeated runs and deliberate sleep timings—can catch races. In CI, we run such tests with pytest-repeat to amplify the signal.
Test 5: Bloom filter sanity for cache penetration
def test_bloom_filter_prevents_penetration(redis_client, bloom_filter):
non_existing_user = "no-such-id"
# assert bloom filter hasn't seen this ID
assert not bloom_filter.contains(non_existing_user)
# access profile, which should skip DB call
profile = get_user_profile(redis_client, db_session, non_existing_user)
assert profile is None
# DB session's query method should not have been called
# In test, you can mock the DB layer to assert zero calls.
4. Integrate into CI – stop the incident before it leaves the branch
Our CI pipeline (GitHub Actions, GitLab CI, Jenkins—whatever you use) gets a new job:
# .github/workflows/test.yml (extract)
cache-consistency-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start Redis container
run: docker-compose -f docker-compose.yml up -d
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run cache consistency tests
run: pytest tests/ -v -m "cache"
- name: Tear down
if: always()
run: docker-compose down
We mark our cache tests with @pytest.mark.cache so we can run them selectively. Once this job turns red, the merge is blocked. The culture shift is immediate: every developer now sees cache bugs as build failures, not “production observations.”
The result: zero cache incidents in production
After integrating the test suite, the graph of cache-related alerts flatlined. The monthly average dropped from 3 incidents to 0 over the next six months. Engineers started writing cache tests proactively—the suite grew from 5 tests to over 30, covering edge cases like TTL drift, large payload serialization, and pipeline failures.
The biggest lesson? You cannot review your way out of a complex state problem. Caching is state; state needs verification against real infrastructure. Mocking Redis is like testing a life raft in a bathtub. Once you pipe real TCP packets and real eviction policies into your tests, the bugs have nowhere to hide.
If your system relies on Redis and you still rely on manual verification or mock-heavy unit tests, set aside an afternoon and build these fixtures. Your phone will thank you at 2 AM.
Top comments (0)