Testland
Browse all skills & agents

redis-cache-tests

Wraps Redis cache testing: EXPIRE / PEXPIRE / TTL verification (Redis 7+ NX/XX/GT/LT flags), cache-aside write-then-invalidate (write source → DEL key → assert fresh read), eviction under memory pressure (maxmemory + allkeys-lru), pub/sub invalidation across nodes, and tenant key-namespacing. Use when Redis is the app's primary cache. For a Memcached app-tier store use memcached-tests; for the browser/HTTP tier use browser-cache-control-tests; a runnable test, not the cache-stampede-reference or cache-coherence-patterns-reference catalogs.

Install with skills.sh (any agent)

npx skills add testland/qa --skill redis-cache-tests
View source

redis-cache-tests

Overview

Redis is the dominant application-tier cache. Per redis.io/docs/latest/commands/expire/ (opens in new window), keys get TTLs via EXPIRE, PEXPIRE, or EXPIREAT - with Redis 7+ flags NX / XX / GT / LT controlling conditional expiry.

This skill wraps test patterns against a real Redis instance (via testcontainers or a dedicated test cluster) - not a mock. Mocks lose the eviction-policy + TTL-tick + pub-sub behaviours that real bugs hide in.

When to use

  • Tests for code that uses Redis as a cache.
  • Verifying eviction policy under memory pressure.
  • Tenant-namespace tests for cache-key isolation.
  • Stampede-mitigation tests per cache-stampede-reference.

Authoring

Install

pip install redis testcontainers      # Python
npm install --save-dev ioredis testcontainers     # Node

Real-Redis test fixture (Python)

import pytest
import redis
from testcontainers.redis import RedisContainer

@pytest.fixture(scope="session")
def redis_url():
    with RedisContainer("redis:7-alpine") as r:
        yield r.get_connection_url()

@pytest.fixture
def r(redis_url):
    client = redis.from_url(redis_url, decode_responses=True)
    yield client
    client.flushdb()  # Reset between tests

Basic TTL tests

Per redis.io (opens in new window):

def test_expire_sets_ttl(r):
    r.set("k", "v")
    assert r.expire("k", 60) == 1
    ttl = r.ttl("k")
    assert 58 <= ttl <= 60

def test_ttl_minus_2_when_key_absent(r):
    assert r.ttl("nonexistent") == -2  # per redis docs

def test_ttl_minus_1_when_no_expiry(r):
    r.set("k", "v")
    assert r.ttl("k") == -1  # exists, no TTL

def test_set_clears_existing_ttl(r):
    r.set("k", "v", ex=60)
    assert r.ttl("k") > 0
    r.set("k", "v2")  # overwrite clears TTL per redis docs
    assert r.ttl("k") == -1

Conditional expire (NX / XX / GT / LT)

def test_expire_nx_only_when_no_ttl(r):
    r.set("k", "v")
    assert r.expire("k", 60, nx=True) == 1
    assert r.expire("k", 120, nx=True) == 0  # already has TTL
    assert r.ttl("k") <= 60

def test_expire_gt_only_extends(r):
    r.set("k", "v", ex=60)
    assert r.expire("k", 120, gt=True) == 1   # 120 > 60
    assert r.expire("k", 30, gt=True) == 0    # 30 < 120

Cache-aside write-then-invalidate

Per cache-coherence-patterns-reference cache-aside pattern:

def test_write_invalidates_cache(r, db):
    db.users.insert({"id": "u1", "name": "alice"})
    r.set("user:u1", '{"id":"u1","name":"alice"}', ex=300)

    # Update via the app's write path
    update_user_via_app("u1", name="bob")  # must DEL cache

    cached = r.get("user:u1")
    assert cached is None, "App should have invalidated cache on write"

Eviction-policy tests

Under memory pressure with maxmemory-policy allkeys-lru:

def test_lru_evicts_oldest_under_pressure(r):
    r.config_set("maxmemory", "1mb")
    r.config_set("maxmemory-policy", "allkeys-lru")

    big_value = "x" * 100_000  # 100 KB

    # Fill cache
    for i in range(20):
        r.set(f"key:{i}", big_value)

    # Touch key:0 to make it recently-used
    r.get("key:0")

    # Add more → should evict middle keys, not key:0
    for i in range(20, 30):
        r.set(f"key:{i}", big_value)

    assert r.exists("key:0")              # Recently touched → kept
    assert not r.exists("key:5")          # Not touched → evicted

Cache stampede mitigation

Per cache-stampede-reference:

import concurrent.futures, threading

def test_lock_prevents_stampede(r):
    call_count = threading.Lock()
    counter = [0]

    def recompute_once():
        with call_count: counter[0] += 1
        return "computed"

    def cached_get(key):
        val = r.get(key)
        if val: return val
        lock_acquired = r.set(f"lock:{key}", "1", nx=True, ex=30)
        if lock_acquired:
            val = recompute_once()
            r.set(key, val, ex=300)
            r.delete(f"lock:{key}")
            return val
        # Wait for the lock holder (simplified)
        for _ in range(50):
            val = r.get(key)
            if val: return val
            __import__("time").sleep(0.01)
        return None

    with concurrent.futures.ThreadPoolExecutor(max_workers=100) as ex:
        results = list(ex.map(lambda _: cached_get("hot"), range(100)))

    assert counter[0] == 1, f"Stampede: recomputed {counter[0]} times"
    assert all(r == "computed" for r in results)

Tenant-namespacing tests

Per cross-tenant-data-leak-tests Test 10:

def test_tenant_namespaced_keys(r, cache):
    cache.set("user:1", "tenant_a_data", tenant_id="A")
    cache.set("user:1", "tenant_b_data", tenant_id="B")

    # Real Redis state should have separate keys
    assert r.get("tenant:A:user:1") == "tenant_a_data"
    assert r.get("tenant:B:user:1") == "tenant_b_data"

    # The application-layer get must respect tenant
    assert cache.get("user:1", tenant_id="A") == "tenant_a_data"
    assert cache.get("user:1", tenant_id="B") == "tenant_b_data"

Running

pytest tests/redis/ -v

testcontainers boots Redis per session; per-test flushdb resets state.

Pub-sub invalidation across nodes

For multi-node cache invalidation (e.g., Redis Sentinel or a pub-sub fan-out):

def test_pubsub_invalidation(r):
    pubsub = r.pubsub()
    pubsub.subscribe("invalidate")

    r.set("k", "v", ex=300)
    r.publish("invalidate", "k")

    msg = next(m for m in pubsub.listen() if m["type"] == "message")
    assert msg["data"] == "k"
    # Other nodes would now `r.delete(msg['data'])`

Parsing results

Redis returns simple types: int (1/0 success codes), string (the value), or None (key absent). Assertions are direct.

For TTL: positive int = remaining seconds, -1 = no TTL, -2 = key absent (per redis.io docs).

CI integration

jobs:
  redis-tests:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7-alpine
        options: --health-cmd "redis-cli ping" --health-interval 10s
        ports: [6379]
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
      - run: pip install -e ".[test]"
      - run: pytest tests/redis/ --tb=short
        env:
          REDIS_URL: redis://localhost:6379

Anti-patterns

Anti-patternWhy it failsFix
Mocking the Redis clientMisses TTL-tick, eviction, pub-subUse testcontainers / real Redis
Hardcoded test sleep time.sleep(60) to test TTLSlow + flakySet tiny TTL (ms via pexpire)
Asserting on exact TTL valueRace vs Redis tickRange assertion (e.g., 58 <= ttl <= 60)
Tests don't FLUSHDB betweenCross-test pollutionPer-test or per-class flush
SET k v EX 0Immediate deletion per redis docsUse positive TTL or PERSIST
Cache-aside without explicit invalidation testLogic bug mergedCover write → cache-state
Tests skip eviction-policyMemory-pressure bugs hideTest maxmemory + policy explicitly
KEYS * in test setupO(N) blocks Redis; flakes under parallel test loadSCAN or per-test isolated DB

Limitations

  • testcontainers startup adds ~3s per test session. Use scope="session" fixtures.
  • Doesn't catch network partitions. Failover behaviour (Sentinel, Cluster) needs toxiproxy or chaos testing.
  • Single-key TTL doesn't compose to multi-key transactions. MULTI/EXEC semantics need their own tests.
  • Eviction is approximate. Redis LRU is sampled; the test needs tolerant assertions.
  • Doesn't replace contract tests between the app and Redis protocol; protocol drift is rare but possible.

References

Related skills

browser-cache-control-tests

Wraps browser-side Cache-Control testing with Playwright (Cypress for legacy stacks): asserting response Cache-Control headers from Network events, ETag round-trips (If-None-Match → 304), service-worker strategies (Workbox cacheFirst / networkFirst / staleWhileRevalidate), and reload semantics (normal vs hard). Covers MDN Cache-Control + RFC 9111. Use when auditing browser-tier caching in E2E tests. For CDN-edge purge use cdn-cache-purge-tests; for the reverse-proxy tier use varnish-test-vtc-syntax; for an app-tier store use redis-cache-tests; SWR directive semantics live in stale-while-revalidate-reference.

cache-coherence-patterns-reference

Pure-reference catalog of cache-coherence patterns across the request path. Defines the five-tier cache stack (browser → CDN → reverse-proxy → application → data store), the per-tier cache-writing patterns (cache-aside, write-through, write-back, write-around, refresh-ahead), and the canonical invalidation strategies (TTL-only, event-driven purge, surrogate keys, version-tagged URLs, soft purge), plus an anti-pattern table and a worked multi-tenant coherence-test example. Deep detail - the RFC 9111 Cache-Control / Vary / ETag directive tables and the cross-tier coherence + per-tier test surface - lives in references/. Use for pattern selection, Cache-Control header design, and coherence audits; use a cache-key-collision check when the question is whether two requests in an existing system collide on a concrete key scheme. Consumed by redis-cache-tests, cdn-cache-purge-tests, varnish-test-vtc-syntax, browser-cache-control-tests, and the cache-key-collision check.

cache-key-discriminator-audit

Audits whether a cache key carries every discriminator the cached response actually depends on, so two requests that must not share a slot cannot collide. Ranks identity discriminators (tenant, user, authorization scope, plan tier) above presentation ones (locale, region, currency, feature flag), maps each missing discriminator to the data-exposure or wrong-content consequence it causes, classifies each key-and-value pair into a critical / high / medium severity band (including the Python lru_cache-on-an-instance-method trap), and writes the fix as a namespaced key builder plus the matching HTTP Vary header. Use when a cache key is being designed or changed, when a per-user or per-tenant response is about to be stored in a shared cache or CDN, or when investigating a report that one user or tenant saw another's data.

cache-stampede-reference

Pure-reference catalog of cache-stampede (thundering-herd) phenomena and mitigations: parallel misses on key expiry trigger simultaneous recomputation (often congestion-collapse), countered by three families - locking, external recomputation near-expiry, and probabilistic early expiration via XFetch (`(time() - delta * beta * log(rand(0,1))) >= expiry`). Use when designing cache-refresh strategy or diagnosing a stampede incident. This is the single failure-mode pattern; for the broader multi-tier pattern catalog use cache-coherence-patterns-reference, for the stale-while-revalidate / stale-if-error extensions use stale-while-revalidate-reference; a reference consumed by redis-cache-tests, not a runnable test.

cdn-cache-purge-tests

Wraps CDN cache-purge testing patterns for Cloudflare (POST /zones/{zone_id}/purge_cache, single-file / everything / cache-tags / hostname / prefix), Fastly (POST purge-by-key / purge-all, surrogate-keys via Surrogate-Key header), and CloudFront (CreateInvalidation API + paths). Covers end-to-end test patterns (write origin → trigger purge → assert edge serves fresh), purge-propagation delay testing (typically 1-30s globally), surrogate-key + cache-tag patterns for group-purge, and Cache-Status header verification (cf-cache-status: HIT/MISS/BYPASS). Use when designing or auditing CDN cache-invalidation workflows.

memcached-tests

Wraps Memcached cache testing against a real container: an inline set/get/expire/no-persistence worked example, with the exhaustive protocol-command tests (set/get/add/cas/incr/decr, TTL 0=never-expire / 30-day Unix-timestamp boundary) and the LRU-eviction, consistent-hashing distribution, ElastiCache Auto Discovery, and CI-wiring deep-dives in references/. Use when writing tests for an application that uses Memcached as its primary cache, when verifying ElastiCache Memcached cluster behaviour, or when contrasting Memcached eviction and distribution semantics against Redis.

stale-while-revalidate-reference

Pure-reference catalog of RFC 5861's stale-while-revalidate + stale-if-error Cache-Control extensions. Defines stale-while-revalidate=N (caches MAY serve a stale response while asynchronously revalidating, up to N seconds after expiry) and stale-if-error=N (caches MAY serve stale on 5xx upstream errors). Distinguishes from RFC 9111's must-revalidate (forbids serving stale) and from manual cache-aside refresh (synchronous). Covers the interaction with the freshness lifetime (max-age) and the cache-stampede-mitigation properties. Use when designing the cache-refresh boundary or auditing existing Cache-Control headers.

varnish-test-vtc-syntax

Wraps the varnishtest CLI + VTC (Varnish Test Case) syntax for testing VCL configurations. Covers the VTC test-file format (varnishtest scripts with server { ... } + client { ... } + varnish v1 -vcl+backend { ... } blocks), the grace-mode + saint-mode behaviours (stale-while-revalidate + stale-if-error equivalents in VCL), the PURGE method handler pattern (vcl_purge subroutine + ACL guards), and surrogate-key invalidation via xkey vmod. Use when authoring or testing Varnish-based caching layers.