Stubbing Redis with ioredis-mock

Cache code is full of branches — hit, miss, stale, expired, evicted — and each one is a bug waiting to happen, yet spinning up a real Redis container to test them makes the suite slow and stateful. This guide is for engineers on Vitest 1.x using the ioredis client who want an in-process fake that behaves like Redis for the commands they actually use, so cache logic and TTL branches are testable at unit speed. It belongs to the database and ORM mocking topic area. Redis is a store you own, so a fake is the right seam; a store you reach over HTTP would instead call for external service simulation. We cover swapping the client, per-test isolation, driving TTL expiry deterministically, and keeping workers from colliding in parallel CI.

Root Cause Analysis

Redis-backed code tends to be tested badly for two opposite reasons. Either the client is hand-mocked with vi.fn() stubs that return canned values — which never exercises the real semantics of SET with EX, INCR, or TTL, so the branch logic is unverified — or a real Redis is booted in CI, which is slow to start, shared between workers, and leaves keys behind that make tests order-dependent. Both miss the target: you want the real command semantics without the real server.

ioredis-mock closes that gap by implementing the Redis command surface in JavaScript over an in-memory store, exposing the same ioredis API. Your code calls get, set, expire, incr, and del exactly as in production, and the fake responds with faithful semantics — including TTL bookkeeping — but entirely in the process’s heap. The remaining risk is command coverage: the fake implements the common surface, not every module or exotic command, so a test that relies on an unimplemented command will behave differently from production. The mitigation is to keep the fake for mainstream key-value, TTL, and counter logic, and to verify anything exotic against a real instance. Within that mainstream, the fake is a true fake in the taxonomy sense — a working simplified implementation — not a hollow stub.

The distinction between a fake and a stub matters because it changes what your tests can prove. A stub built from vi.fn() returns a fixed value regardless of prior state, so a test that sets a key and then reads it only passes because you wired both calls to agree — it verifies your wiring, not your logic. The fake actually stores the value, so the read returns what the write put there, and a bug in the key you compute or the TTL you set surfaces as a genuine mismatch. That is the difference between a test that documents your assumptions and a test that challenges them, and it is why a working in-memory implementation is worth the dependency even though a handful of vi.fn() calls would compile.

Swapping ioredis for its in-memory fake The cache module imports ioredis in production and the ioredis-mock fake in tests, with identical API so no call sites change. Cache module get / set / expire production: ioredis real server, TCP test: ioredis-mock in-heap, same API identical API — no call site changes
Same client surface, real command semantics, no server.

Reproducible Setup

Install Vitest and the fake. ioredis-mock is a drop-in that mirrors the ioredis constructor, so the only change your code needs is which module the client is imported from — and even that is handled by a module mock rather than a code edit, keeping production and test paths identical.

npm install -D vitest ioredis-mock
npm install ioredis

Construct the client behind one module so a test can substitute it.

// src/cache/client.ts
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
// src/cache/user-cache.ts — the system under test
import { redis } from './client';

export async function getCachedUser(id: string, load: () => Promise<object>) {
  const hit = await redis.get(`user:${id}`);
  if (hit) return JSON.parse(hit);
  const fresh = await load();
  await redis.set(`user:${id}`, JSON.stringify(fresh), 'EX', 60); // 60s TTL
  return fresh;
}

Implementation

Step 1 — Redirect the client module to the fake. Point vi.mock at ioredis-mock so every new Redis() in the module graph builds an in-memory instance with the real API.

// src/cache/user-cache.test.ts
import { vi, beforeEach, afterEach, describe, it, expect } from 'vitest';

vi.mock('ioredis', () => import('ioredis-mock'));

import { redis } from './client';
import { getCachedUser } from './user-cache';

Step 2 — Flush between tests for isolation. The fake shares its store across instances constructed from the same connection, so keys persist. Flush in afterEach so every test starts empty.

afterEach(async () => {
  await redis.flushall(); // wipe the in-memory keyspace between tests
});

Step 3 — Assert the cache-miss and cache-hit branches. Use a spy loader so you can prove the loader runs exactly once on a miss and never on a subsequent hit — the core contract of a cache.

it('loads on miss, then serves from cache', async () => {
  const load = vi.fn().mockResolvedValue({ id: 'u1', name: 'Ada' });

  const first = await getCachedUser('u1', load);   // miss
  const second = await getCachedUser('u1', load);  // hit

  expect(first).toEqual({ id: 'u1', name: 'Ada' });
  expect(second).toEqual({ id: 'u1', name: 'Ada' });
  expect(load).toHaveBeenCalledTimes(1); // loader ran only on the miss
});

Step 4 — Drive TTL expiry deterministically. Real time makes TTL tests slow and flaky. ioredis-mock honors TTL, so instead of waiting you assert ttl bookkeeping and force expiry by deleting or by advancing fake timers where your code reads the clock.

it('sets a 60-second TTL on write', async () => {
  await getCachedUser('u1', async () => ({ id: 'u1' }));
  const ttl = await redis.ttl('user:u1');
  expect(ttl).toBeGreaterThan(0);
  expect(ttl).toBeLessThanOrEqual(60);
});

it('reloads after the key is evicted', async () => {
  const load = vi.fn().mockResolvedValue({ id: 'u1' });
  await getCachedUser('u1', load);
  await redis.del('user:u1');            // simulate eviction
  await getCachedUser('u1', load);
  expect(load).toHaveBeenCalledTimes(2); // miss again after eviction
});
Cache branches exercised by the fake The fake honors real semantics so a miss loads and writes, a hit serves from memory, and an eviction forces a reload. One cache path, three verified branches MISS load + SET EX 60 HIT serve from heap EVICTED TTL lapse, reload loader call count is the assertion that pins each branch 1 on miss, 0 on hit, 1 more after eviction
Miss, hit, and eviction each verified through the loader's call count.

Step 5 — Isolate the keyspace per worker in parallel CI. Because the fake keys off the connection string, distinct workers using the same URL can share a store. Namespace the connection by worker id, or rely on the per-test flushall, so parallel runs never read each other’s keys.

const workerId = process.env.VITEST_WORKER_ID ?? '0';
export const redis = new Redis(`redis://mock-${workerId}`); // per-worker keyspace

Verification

Prove the fake is exercising real semantics rather than returning canned values by asserting on state that only genuine command behavior would produce: after a set ... EX 60, ttl must be a positive number no greater than 60; after a flushall, dbsize must be zero; after two incr calls, the value must be 2. A hand-rolled stub would pass none of these without extra wiring. Then run the file alone and in the full parallel suite and confirm identical results, which shows the flush-and-namespace isolation holds.

npx vitest run src/cache/user-cache.test.ts

Matching results across isolated and parallel runs, plus TTL and counter assertions that reflect true Redis semantics, confirm the fake is behaving as a real in-memory Redis for the commands under test.

The two isolation levers — flushing per test and namespacing per worker — solve different collisions, and it helps to see them together. Flushing prevents keys leaking between sequential tests in the same file; namespacing prevents parallel workers that share a connection string from reading one another’s keys. Applying both is belt and braces, and cheap, so it is the recommended default.

Two isolation levers for the Redis fake Flushing the keyspace isolates sequential tests within a file while namespacing the connection isolates parallel workers from each other. Two collisions, two levers Within a file flushall in afterEach sequential tests start empty no leaked keys Across workers namespace by worker id parallel keyspaces stay private no cross-worker reads apply both for full isolation
Flush isolates within a file; namespacing isolates across parallel workers.

Troubleshooting

Symptom: keys from one test appear in the next. Diagnosis: the fake persists its store across instances built from the same connection string, and no flush is running. Fix: call await redis.flushall() in afterEach, and namespace the connection per worker so parallel files do not share a keyspace.

Symptom: a command returns undefined or throws “not implemented”. Diagnosis: the code uses a command or module the fake does not implement, such as a specific stream or search command. Fix: keep the fake for mainstream key-value, TTL, and counter operations, and verify exotic commands against a real Redis instance in a separate, smaller suite.

Symptom: TTL never appears to expire during a test. Diagnosis: the test is waiting on wall-clock time, which is both slow and unreliable. Fix: assert the ttl value directly rather than waiting, or simulate expiry with del; if your code reads the clock, drive it with Vitest fake timers instead of real delays.

FAQ

Is ioredis-mock a real Redis or just canned responses?

It is a working in-memory implementation of the Redis command surface, so it genuinely stores keys, honors TTLs, increments counters, and expires entries — it is a fake in the precise sense of a simplified but functional stand-in, not a hollow stub returning fixed values. That is exactly why it can verify branch logic that a vi.fn() mock cannot. Its limit is coverage: it implements the common commands, not every exotic or module-specific one, so anything unusual should be checked against a real server.

When should I use a real Redis container instead of the fake?

Reach for a real container when your code depends on commands or behaviors the fake does not implement — certain stream operations, Lua scripting edge cases, multi-node sharding behavior, or eviction-policy specifics — because there the fake and production would diverge. For the overwhelming majority of application caching, which is GET/SET/EXPIRE/INCR/DEL, the fake is faithful and far faster, so keep it as the default and reserve a small real-Redis suite for the genuinely exotic cases.

How do I test TTL expiry without slow real-time waits?

Do not wait on wall-clock time; it makes tests slow and flaky. Assert the TTL value directly with redis.ttl(key) to prove the expiry was set correctly, and simulate the eviction itself with del to drive the reload branch. If the code under test reads the current time to decide staleness, control that with Vitest’s fake timers so expiry is deterministic and instantaneous.