Skip to main content
Back to Blog
Guide
2026-05-02

Testcontainers Redis Node.js — Complete Guide 2026

Master Testcontainers for Redis in Node.js. Real cache and pub/sub tests with Docker, ioredis, node-redis, cluster mode, and CI/CD patterns.

Testcontainers Redis Node.js Complete Guide

Redis powers caching, session storage, rate limiting, pub/sub messaging, distributed locks, leaderboards, and queue systems in nearly every modern Node.js application. The trouble is that Redis behavior — particularly around expiration, eviction policies, Lua scripts, and Streams — cannot be faithfully emulated by in-memory mocks like ioredis-mock. The mocks lag behind real Redis features, behave subtly differently around TTL precision, and let teams write tests that pass against the mock but fail in production. Testcontainers solves this by spinning up a real Redis container per test suite, with zero docker-compose overhead.

This guide is a hands-on walkthrough of Testcontainers with Redis in Node.js for 2026. We cover installation, container configuration, both ioredis and node-redis client setup, pub/sub testing, Streams, Lua scripting, cluster mode emulation, container reuse for fast local dev, and the patterns that scale to large monorepos. Every code example is working TypeScript with Vitest.


Key Takeaways

  • GenericContainer is used for Redis because Testcontainers does not ship a dedicated Redis module — the GenericContainer pattern is two lines longer
  • ioredis and node-redis both work; ioredis has better cluster and Sentinel support, node-redis is the official client
  • TTL precision in real Redis is millisecond-accurate, while mocks often round to seconds
  • Container reuse drops startup from 3 seconds to under 500ms on warm machines
  • Cluster mode requires multiple containers networked together — covered in this guide
  • CI/CD configuration is one line in GitHub Actions because Docker is preinstalled

Why Use Testcontainers for Redis

ioredis-mock and redis-mock have served as the default in-process test doubles for years, but they each have gaps. ioredis-mock does not fully implement Streams, behaves differently with expirations under load, and is missing several modules (RedisJSON, RediSearch, RedisBloom). redis-mock implements only the v4 protocol and is unmaintained. Both let you write tests that pass against the mock but fail against real Redis.

The alternative is a shared Redis instance in docker-compose. This works but couples test execution to a separate setup step, makes parallel test runs awkward (key collisions), and accumulates leftover data across runs.

Testcontainers gives every test suite a fresh, real Redis instance with a unique port, automatic cleanup, and one-line setup.


Installation

npm install --save-dev testcontainers
npm install --save-dev vitest ioredis
# or
npm install --save-dev vitest redis

Verify Docker:

docker info

Configure Vitest:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    testTimeout: 30_000,
    hookTimeout: 30_000,
    pool: 'forks',
  },
});

Your First Test (ioredis)

import { GenericContainer, StartedTestContainer } from 'testcontainers';
import Redis from 'ioredis';
import { describe, it, beforeAll, afterAll, expect } from 'vitest';

describe('Redis integration', () => {
  let container: StartedTestContainer;
  let redis: Redis;

  beforeAll(async () => {
    container = await new GenericContainer('redis:7.4-alpine')
      .withExposedPorts(6379)
      .start();
    redis = new Redis({
      host: container.getHost(),
      port: container.getMappedPort(6379),
    });
  });

  afterAll(async () => {
    redis.disconnect();
    await container.stop();
  });

  it('sets and gets a key', async () => {
    await redis.set('hello', 'world');
    const value = await redis.get('hello');
    expect(value).toBe('world');
  });

  it('expires keys correctly', async () => {
    await redis.set('temp', 'value', 'PX', 100);
    await new Promise(resolve => setTimeout(resolve, 200));
    const value = await redis.get('temp');
    expect(value).toBeNull();
  });
});

The expiration test would be flaky against ioredis-mock because of how it rounds TTLs. Against real Redis it works perfectly.


Reusable Setup Helper

Extract the boilerplate to a fixture so every test file is concise:

// test-helpers/redis.ts
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import Redis from 'ioredis';

export interface RedisFixture {
  container: StartedTestContainer;
  redis: Redis;
}

export async function startRedis(): Promise<RedisFixture> {
  const container = await new GenericContainer('redis:7.4-alpine')
    .withExposedPorts(6379)
    .start();
  const redis = new Redis({
    host: container.getHost(),
    port: container.getMappedPort(6379),
  });
  return { container, redis };
}

export async function stopRedis(fixture: RedisFixture): Promise<void> {
  fixture.redis.disconnect();
  await fixture.container.stop();
}

Then in tests:

import { startRedis, stopRedis, RedisFixture } from './test-helpers/redis';

let fixture: RedisFixture;

beforeAll(async () => { fixture = await startRedis(); });
afterAll(async () => { await stopRedis(fixture); });

node-redis Client Variant

If you prefer the official node-redis client:

import { createClient, RedisClientType } from 'redis';

let client: RedisClientType;

beforeAll(async () => {
  container = await new GenericContainer('redis:7.4-alpine')
    .withExposedPorts(6379)
    .start();
  client = createClient({
    url: `redis://${container.getHost()}:${container.getMappedPort(6379)}`,
  });
  await client.connect();
});

afterAll(async () => {
  await client.quit();
  await container.stop();
});

it('sets and gets', async () => {
  await client.set('key', 'value');
  expect(await client.get('key')).toBe('value');
});

Per-Test Isolation

You have three options for isolating tests against the same Redis container.

Option 1: FLUSHDB between tests. Simple but slow if you have many keys:

afterEach(async () => {
  await redis.flushdb();
});

Option 2: Unique key prefixes per test. Faster but requires discipline:

const prefix = `test:${expect.getState().currentTestName}:`;
await redis.set(`${prefix}user:1`, 'data');

Option 3: Multiple databases. Redis has 16 logical databases. Each test can SELECT a different one:

let dbCounter = 0;
beforeEach(async () => {
  await redis.select(++dbCounter % 16);
  await redis.flushdb();
});

For most test suites, option 1 is the right default.


Testing Pub/Sub

Pub/sub testing is where mocks really struggle. With real Redis, it just works:

it('publishes and subscribes', async () => {
  const subscriber = new Redis({
    host: container.getHost(),
    port: container.getMappedPort(6379),
  });
  const messages: string[] = [];

  await subscriber.subscribe('events');
  subscriber.on('message', (channel, msg) => {
    messages.push(msg);
  });

  await redis.publish('events', 'hello');
  await redis.publish('events', 'world');

  await new Promise(r => setTimeout(r, 100));

  expect(messages).toEqual(['hello', 'world']);
  subscriber.disconnect();
});

You need a separate subscriber connection because subscribers cannot run other commands.


Testing Redis Streams

Streams are notoriously absent from most mocks:

it('appends and reads from a stream', async () => {
  await redis.xadd('events', '*', 'type', 'click', 'user', 'alice');
  await redis.xadd('events', '*', 'type', 'view', 'user', 'bob');

  const entries = await redis.xrange('events', '-', '+');
  expect(entries.length).toBe(2);
  expect(entries[0][1]).toContain('click');
});

For consumer groups:

await redis.xgroup('CREATE', 'events', 'group1', '0');
const pending = await redis.xreadgroup(
  'GROUP', 'group1', 'consumer1',
  'COUNT', 10, 'STREAMS', 'events', '>'
);

Testing Lua Scripts

const script = `
  local current = redis.call('GET', KEYS[1])
  if current == false then
    redis.call('SET', KEYS[1], ARGV[1])
    return 1
  end
  return 0
`;

it('runs a Lua script atomically', async () => {
  const setIfNotExists = await redis.eval(script, 1, 'lockkey', 'lockvalue');
  expect(setIfNotExists).toBe(1);

  const setAgain = await redis.eval(script, 1, 'lockkey', 'newvalue');
  expect(setAgain).toBe(0);
});

Cluster Mode Testing

For Redis Cluster, run multiple containers on a shared network. The official redis image supports cluster mode via the --cluster-enabled yes flag, but configuring six containers (three primary, three replica) by hand is tedious. The pragmatic approach is to use the grokzen/redis-cluster Docker image which orchestrates a 6-node cluster in a single container:

container = await new GenericContainer('grokzen/redis-cluster:7.4.0')
  .withExposedPorts(7000, 7001, 7002, 7003, 7004, 7005)
  .withEnvironment({ IP: '0.0.0.0' })
  .start();

const cluster = new Redis.Cluster([
  { host: container.getHost(), port: container.getMappedPort(7000) },
  { host: container.getHost(), port: container.getMappedPort(7001) },
  { host: container.getHost(), port: container.getMappedPort(7002) },
]);

Configuration Options

ConfigurationMethodUse Case
Persistence.withCommand(['redis-server', '--appendonly', 'yes'])Test AOF behavior
Custom config file.withCopyFilesToContainer([{ source: 'redis.conf', target: '/etc/redis.conf' }])Test specific configs
Memory limit.withCommand(['redis-server', '--maxmemory', '100mb'])Test eviction
Eviction policy.withCommand(['redis-server', '--maxmemory-policy', 'allkeys-lru'])Test cache eviction
Password.withCommand(['redis-server', '--requirepass', 'secret'])Test authentication
ModulesUse redis/redis-stack:latest imageRediSearch, RedisJSON, etc.

Container Reuse

container = await new GenericContainer('redis:7.4-alpine')
  .withExposedPorts(6379)
  .withReuse()
  .start();

Enable in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

First run takes 3 seconds. Subsequent runs reconnect in 200ms.


CI/CD Configuration

GitHub Actions:

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

Common Pitfalls

Forgetting to disconnect. Both ioredis and node-redis hold open connections that keep the Node.js event loop alive. If you forget disconnect() or quit(), Vitest will hang at the end of the run. Always disconnect in afterAll.

Subscriber on the same client. A Redis client cannot publish and subscribe at the same time. Create separate clients for each role.

Flush during pub/sub. FLUSHDB does not unsubscribe active subscribers. If a previous test left a subscriber active, weird interference can happen. Disconnect subscribers in afterEach.

Wrong Redis version. Pin to a specific version. Redis 7.0 added Functions, 7.2 added Sharded Pub/Sub, 7.4 expanded Streams. Tests written for 7.4 features will fail on older containers.


Conclusion

Testcontainers with Redis gives Node.js teams real, isolated Redis instances per test suite, eliminating the gap between mocks and production. Pub/sub, Streams, Lua scripting, cluster mode, and TTL precision all behave exactly like production. With container reuse, local iteration is fast, and CI integration requires zero configuration.

Browse our QA skills directory for related cache and queue testing patterns, or read the Testcontainers Best Practices 2026 for advanced architecture patterns.

Testcontainers Redis Node.js — Complete Guide 2026 | QASkills.sh