redis-mock vs Testcontainers for Redis Testing: When to Use Each

redis-mock vs Testcontainers for Redis Testing: When to Use Each

Testing code that depends on Redis presents a fundamental choice: simulate Redis in-process with a mock library, or spin up a real Redis instance via Testcontainers. Both approaches have distinct trade-offs that affect test speed, reliability, and the bugs you catch. This guide walks through both options in depth, with runnable examples.

The Core Problem with Redis Tests

Redis is not just a key-value store. It has data structures (sorted sets, streams, pub/sub), expiry semantics, Lua scripting, and cluster behavior. A mock that gets 80% of the API right will let 20% of bugs through to production.

At the same time, real Redis is a network process. Starting it per-test-suite adds seconds to CI. If you have 50 test suites, that's minutes of overhead—and flaky behavior when port conflicts or slow container starts occur.

The right tool depends on what you're testing.

redis-mock: Fast, In-Process Simulation

What it is

redis-mock (Node.js) and fakeredis / fakeredis2 (Python) implement the Redis protocol in-process. No network, no Docker, no startup latency.

Node.js Setup

npm install --save-dev redis-mock
// cache.js
const redis = require('redis');
const client = redis.createClient(process.env.REDIS_URL);

async function getOrFetch(key, fetchFn, ttlSeconds = 300) {
  const cached = await client.get(key);
  if (cached) return JSON.parse(cached);
  
  const value = await fetchFn();
  await client.setEx(key, ttlSeconds, JSON.stringify(value));
  return value;
}

module.exports = { getOrFetch };
// cache.test.js
const redisMock = require('redis-mock');
const { getOrFetch } = require('./cache');

jest.mock('redis', () => redisMock);

describe('getOrFetch', () => {
  let client;

  beforeEach(() => {
    client = redisMock.createClient();
    client.flushall();
  });

  test('calls fetchFn on cache miss', async () => {
    const fetchFn = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
    
    const result = await getOrFetch('user:1', fetchFn);
    
    expect(fetchFn).toHaveBeenCalledTimes(1);
    expect(result).toEqual({ id: 1, name: 'Alice' });
  });

  test('returns cached value on hit', async () => {
    const fetchFn = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
    
    await getOrFetch('user:1', fetchFn);
    const result = await getOrFetch('user:1', fetchFn);
    
    expect(fetchFn).toHaveBeenCalledTimes(1);
    expect(result).toEqual({ id: 1, name: 'Alice' });
  });

  test('different keys are independent', async () => {
    const fetch1 = jest.fn().mockResolvedValue({ id: 1 });
    const fetch2 = jest.fn().mockResolvedValue({ id: 2 });
    
    await getOrFetch('user:1', fetch1);
    await getOrFetch('user:2', fetch2);
    
    expect(fetch1).toHaveBeenCalledTimes(1);
    expect(fetch2).toHaveBeenCalledTimes(1);
  });
});

Python Setup with fakeredis

pip install fakeredis
# cache.py
import json
import redis

_client = None

def get_client():
    global _client
    if _client is None:
        _client = redis.Redis.from_url(os.environ['REDIS_URL'])
    return _client

def get_or_fetch(key: str, fetch_fn, ttl: int = 300):
    client = get_client()
    cached = client.get(key)
    if cached:
        return json.loads(cached)
    
    value = fetch_fn()
    client.setex(key, ttl, json.dumps(value))
    return value
# test_cache.py
import fakeredis
import pytest
from unittest.mock import patch, MagicMock
from cache import get_or_fetch

@pytest.fixture(autouse=True)
def fake_redis(monkeypatch):
    server = fakeredis.FakeServer()
    fake_client = fakeredis.FakeRedis(server=server)
    monkeypatch.setattr('cache.get_client', lambda: fake_client)
    yield fake_client
    fake_client.flushall()

def test_cache_miss_calls_fetch(fake_redis):
    fetch = MagicMock(return_value={'id': 1, 'name': 'Alice'})
    result = get_or_fetch('user:1', fetch)
    
    fetch.assert_called_once()
    assert result == {'id': 1, 'name': 'Alice'}

def test_cache_hit_skips_fetch(fake_redis):
    fetch = MagicMock(return_value={'id': 1})
    get_or_fetch('user:1', fetch)
    get_or_fetch('user:1', fetch)
    
    assert fetch.call_count == 1

def test_ttl_expiry(fake_redis):
    fetch = MagicMock(return_value={'id': 1})
    get_or_fetch('user:1', fetch, ttl=1)
    
    # Simulate TTL expiry
    fake_redis.delete('user:1')
    
    get_or_fetch('user:1', fetch)
    assert fetch.call_count == 2

What redis-mock Gets Right (and Wrong)

Gets right:

  • String GET/SET/DEL
  • INCR/DECR
  • EXPIRE/TTL (time-based in fakeredis2, simulated in older versions)
  • Hash, list, set operations
  • Basic sorted set operations

Gets wrong or incomplete:

  • Lua scripting (EVAL) — often stubbed or ignored
  • Redis cluster topology / MOVED errors
  • Pub/sub timing behavior
  • WAIT command
  • Some newer commands (LMPOP, ZMPOP, LPOS)
  • TLS/AUTH semantics

Testcontainers: Real Redis, Controlled Environment

What it is

Testcontainers spins up an actual Redis Docker container scoped to your test run. You get the real Redis binary—full protocol support, real expiry, real error codes.

Node.js Setup

npm install --save-dev testcontainers
// redis-container.js (shared test helper)
const { GenericContainer } = require('testcontainers');
const redis = require('redis');

let container;
let client;

async function startRedis() {
  container = await new GenericContainer('redis:7-alpine')
    .withExposedPorts(6379)
    .withStartupTimeout(30000)
    .start();

  const host = container.getHost();
  const port = container.getMappedPort(6379);

  client = redis.createClient({ url: `redis://${host}:${port}` });
  await client.connect();
  
  return { client, host, port };
}

async function stopRedis() {
  await client?.quit();
  await container?.stop();
}

module.exports = { startRedis, stopRedis };
// sorted-set-leaderboard.test.js
const { startRedis, stopRedis } = require('./redis-container');
const { updateScore, getTopN } = require('./leaderboard');

let redisClient;

beforeAll(async () => {
  const { client } = await startRedis();
  redisClient = client;
  // Inject into your module
  jest.mock('redis', () => ({ createClient: () => redisClient }));
}, 60000);

afterAll(async () => {
  await stopRedis();
});

beforeEach(async () => {
  await redisClient.flushAll();
});

test('returns top 3 players in order', async () => {
  await updateScore('alice', 100);
  await updateScore('bob', 200);
  await updateScore('carol', 150);
  await updateScore('dave', 50);

  const top3 = await getTopN(3);
  expect(top3).toEqual(['bob', 'carol', 'alice']);
});

test('updates existing score', async () => {
  await updateScore('alice', 100);
  await updateScore('alice', 200);

  const top1 = await getTopN(1);
  expect(top1[0]).toBe('alice');
  
  const score = await redisClient.zScore('leaderboard', 'alice');
  expect(score).toBe(200);
});

Python Setup

pip install testcontainers[redis]
# test_leaderboard_real.py
import pytest
import redis
from testcontainers.redis import RedisContainer
from leaderboard import update_score, get_top_n

@pytest.fixture(scope='session')
def redis_container():
    with RedisContainer('redis:7-alpine') as container:
        yield container

@pytest.fixture(scope='session')
def redis_client(redis_container):
    client = redis.Redis(
        host=redis_container.get_container_host_ip(),
        port=redis_container.get_exposed_port(6379),
        decode_responses=True
    )
    return client

@pytest.fixture(autouse=True)
def flush_redis(redis_client):
    redis_client.flushall()
    yield

def test_sorted_set_ordering(redis_client, monkeypatch):
    monkeypatch.setattr('leaderboard._client', redis_client)
    
    update_score('alice', 100)
    update_score('bob', 200)
    update_score('carol', 150)
    
    top = get_top_n(3)
    assert top == ['bob', 'carol', 'alice']

def test_lua_script_atomic_conditional(redis_client):
    # This test would FAIL with redis-mock — Lua is real here
    script = """
    local current = redis.call('GET', KEYS[1])
    if current and tonumber(current) >= tonumber(ARGV[1]) then
        return 0
    end
    redis.call('SET', KEYS[1], ARGV[1])
    return 1
    """
    
    redis_client.set('highscore', 100)
    
    # Should not update (150 < 100 is false, so update)
    result = redis_client.eval(script, 1, 'highscore', 150)
    assert result == 1
    assert redis_client.get('highscore') == '150'
    
    # Should not update (50 < 150)
    result = redis_client.eval(script, 1, 'highscore', 50)
    assert result == 0
    assert redis_client.get('highscore') == '150'

Container Reuse with withReuse()

Testcontainers supports container reuse across test runs to amortize startup cost:

container = await new GenericContainer('redis:7-alpine')
  .withExposedPorts(6379)
  .withReuse()  // reuse if already running
  .start();

With reuse enabled, the first test run starts Redis in ~2s; subsequent runs reconnect instantly. You must call flushAll() in beforeEach to prevent state leakage.

Decision Framework

Criterion redis-mock / fakeredis Testcontainers
Startup time ~0ms 2–10s (first run)
No Docker required Yes No
Lua scripting No Yes
Cluster behavior No Yes (with cluster image)
Real expiry semantics Partial Yes
CI reliability High Medium (Docker required)
Debugging Easy (in-process) Harder (separate process)

Use redis-mock/fakeredis when:

  • You're testing business logic that happens to use Redis for simple caching
  • Test speed matters more than behavioral accuracy
  • Running on CI without Docker (e.g., GitHub Actions free tier without Docker service)
  • The Redis operations are GET/SET/DEL/INCR only

Use Testcontainers when:

  • You use Lua scripts, sorted sets with ZRANGEBYSCORE edge cases, or streams
  • You're testing Redis-specific error handling (MOVED, CLUSTERDOWN)
  • You've been burned by mock/real divergence in the past
  • Integration tests that cover the full stack

Hybrid Strategy

Most mature codebases use both:

  1. Unit tests use redis-mock for speed. Test your caching logic, key generation, serialization.
  2. Integration tests use Testcontainers. Test your actual Redis commands, error handling, and data structure operations.
// jest.config.js
module.exports = {
  projects: [
    {
      displayName: 'unit',
      testMatch: ['**/*.unit.test.js'],
      // No setup needed — redis-mock injected per file
    },
    {
      displayName: 'integration',
      testMatch: ['**/*.integration.test.js'],
      globalSetup: './test/redis-global-setup.js',
      globalTeardown: './test/redis-global-teardown.js',
    }
  ]
};

This gives you fast feedback during development (unit tests run in <1s) and confidence before deploy (integration tests catch real Redis behavior).

Common Pitfalls

Pitfall 1: Forgetting to flush between tests

Both redis-mock and Testcontainers maintain state between tests. Always flush:

beforeEach(async () => {
  await client.flushAll(); // not flushDb() — flushAll clears all databases
});

Pitfall 2: Connection pooling with mocks

Some Redis clients create connection pools. When mocking, ensure all pool connections point to the mock:

// Bad: only mocks the first connection
jest.mock('redis', () => ({
  createClient: () => redisMock.createClient()
}));

// Good: use a shared mock instance
const mockClient = redisMock.createClient();
jest.mock('redis', () => ({
  createClient: () => mockClient
}));

Pitfall 3: Async mock not matching real async behavior

redis-mock older versions return synchronous values. The real redis v4+ client is fully async. Ensure your mock setup uses the same async interface:

// redis-mock with promise-based API wrapper
const redisMock = require('redis-mock');
const { promisify } = require('util');

const client = redisMock.createClient();
client.getAsync = promisify(client.get).bind(client);
client.setAsync = promisify(client.set).bind(client);

Pitfall 4: Testcontainers startup timeout in slow CI

Default startup timeout is often too low. Set it explicitly:

const container = await new GenericContainer('redis:7-alpine')
  .withExposedPorts(6379)
  .withStartupTimeout(60000) // 60s for slow CI
  .start();

Conclusion

Neither redis-mock nor Testcontainers is universally better. Use mocks for fast unit tests of logic that interacts with Redis. Use Testcontainers for integration tests that must exercise real Redis behavior—especially sorted sets, streams, Lua, and error conditions. A two-tier strategy gives you both speed and correctness without compromise.

Read more

Start now free