Testing Redis Clients in Node.js with Testcontainers Node

Automated testing is an essential aspect of ensuring the quality of any software product. However, it can often be challenging to test systems that rely on databases or other external resources.

Luckily, Testcontainers is a great project that makes setting up external resources more comfortable by providing a programmatic interface to run Docker containers in testing scenarios. In this article, we'll be looking at a community port of Testcontainers for Node.js (testcontainers-node), and how we can run tests that rely on both a single Redis and a Redis Cluster.

The examples below will leverage mocha as a test runner, chai as an assertion library, and ioredis as our backing Redis client.

Before you go any further, you can skip this article altogether and go straight to some working examples on GitHub if that's more your style.

Testing against a single Redis

Testing against a single Redis instance is exceptionally straightforward.

First, we will define a describe block with a before hook. In our before hook, we will create our Docker container that will run Redis, and we will also make our Redis client instance. The before hook is run before any tests are invoked.

const Redis = require("ioredis");
const { GenericContainer } = require("testcontainers");

describe("RedisTest", () => {
  let container;
  let redisClient;

  before(async () => {
    // "redis" is the name of the Docker imaage to download and run
    container = await new GenericContainer("redis")
      // exposes the internal Docker port to the host machine
      .withExposedPorts(6379)
      .start();

    redisClient = new Redis({
      host: container.getHost(),
      // retrieves the port on the host machine which maps
      // to the exposed port in the Docker container
      port: container.getMappedPort(6379),
    });
  });
});

Once we have our Docker container and Redis client created, we will define an after hook to clean up the resources created in the before hook. The after hook is called when all tests are done executing.

const Redis = require("ioredis");
const { GenericContainer } = require("testcontainers");

describe("RedisTest", () => {
  let container;
  let redisClient;

  before(async () => { ... });

  after(async () => {
    redisClient && (await redisClient.quit());
    container && (await container.stop());
  });
});

Now that our Docker container and Redis client are created, we can write a test that will invoke a command against the running Redis.

const Redis = require("ioredis");
const { GenericContainer } = require("testcontainers");
const { expect } = require("chai");

describe("RedisTest", () => {
  let container;
  let redisClient;

  before(async () => { ... });

  after(async () => { ... });

  it("should set and retrieve values from Redis", async () => {
    await redisClient.set("key", "val");
    expect(await redisClient.get("key")).to.equal("val");
  });
});

Putting it all together, we now have a test that can invoke commands against a running Redis instance and perform assertions on the results.

const Redis = require("ioredis");
const { GenericContainer } = require("testcontainers");
const { expect } = require("chai");

describe("RedisTest", () => {
  let container;
  let redisClient;

  before(async () => {
    // "redis" is the name of the Docker imaage to download and run
    container = await new GenericContainer("redis")
      // exposes the internal Docker port to the host machine
      .withExposedPorts(6379)
      .start();

    redisClient = new Redis({
      host: container.getHost(),
      // retrieves the port on the host machine which maps
      // to the exposed port in the Docker container
      port: container.getMappedPort(6379),
    });
  });

  after(async () => {
    redisClient && (await redisClient.quit());
    container && (await container.stop());
  });

  it("should set and retrieve values from Redis", async () => {
    await redisClient.set("key", "val");
    expect(await redisClient.get("key")).to.equal("val");
  });
});

Testing against a Redis Cluster

Testing against a Redis Cluster can be a bit more complicated than against a single Redis. This is primarily because the Docker container's local network will not map to the same IP and ports as on the host machine. Still, luckily the ioredis library supports the needed configuration to make it possible.

Using our before hook from the previous example, we will need to make a few small changes to run a Redis cluster rather than a single Redis in our Docker container. These changes include:

  • Changing our Redis Docker image to grokzen/redis-cluster.
  • Creating a Docker network.
  • Creating a list of Redis nodes to connect to.
  • Create a NAT map to map from the internal Docker network to the host network.

It is necessary to create a NAT map as once our backing Redis client is connected to any node in the cluster; it will attempt to auto-discover any other nodes that belong to the cluster. Unfortunately, when the client discovers new nodes, the nodes will be referenced by local addresses to the Docker container rather than the host machine. The NAT map will allow the client to properly translate the internal Docker address to an available address on the host machine.

const Redis = require("ioredis");
const { GenericContainer, Network, Wait } = require("testcontainers");

describe("RedisTest", () => {
  let container;
  let network;
  let redisClient;

  before(async () => {
    // "grokzen/redis-cluster" exposes 6 Redis nodes
    // on ports 7000 - 7005
    const ports = [7000, 7001, 7002, 7003, 7004, 7005];

    // we create a new Docker network so that we have a consistent way
    // to retrieve the internal addresses of the Redis nodes to build
    // the NAT map
    network = await new Network().start();

    // "grokzen/redis-cluster" is the name of the Docker
    // image to download and run
    container = await new GenericContainer("grokzen/redis-cluster")
      // exposes each of the internal Docker ports listed
      // in `ports` to the host machine
      .withExposedPorts(...ports)
      .withNetworkMode(network.getName())
      .withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
      .start();

    const networkIpAddress = container.getIpAddress(network.getName());

    const dockerHost = container.getHost();
    const hosts = ports.map((port) => {
      // { host: "localhost", port: 55305 }
      return { host: dockerHost, port: container.getMappedPort(port) };
    });

    /**
     * {
     *   "192.168.16.2:7000": { host: "127.0.0.1", port: 55305 }",
     *   "192.168.16.2:7001": { host: "127.0.0.1", port: 55306 }",
     *   ...
     * }
     */
    const natMap = ports.reduce((map, port) => {
      const hostPort = container.getMappedPort(port);
      const internalAddress = `${networkIpAddress}:${port}`;
      map[internalAddress] = { host: dockerHost, port: hostPort };
      return map;
    }, {});

    redisClient = new Redis.Cluster(hosts, { natMap });
  });
});

With our Redis cluster container running and our Redis.Cluster client created, we now need to make sure we remember to add our newly created network resource to our after hook.

const Redis = require("ioredis");
const { GenericContainer, Network, Wait } = require("testcontainers");

describe("RedisTest", () => {
  let container;
  let network;
  let redisClient;

  before(async () => { ... });

  after(async () => {
    redisClient && (await redisClient.quit());
    container && (await container.stop());
    network && (await network.stop());
  });
});

Everything from this point forward is the same as consuming a single Redis. You can see it all put together below.

const Redis = require("ioredis");
const { GenericContainer, Network, Wait } = require("testcontainers");
const { expect } = require("chai");

describe("RedisClusterTest", () => {
  let container;
  let network;
  let redisClient;

  before(async () => {
    // "grokzen/redis-cluster" exposes 6 Redis nodes
    // on ports 7000 - 7005
    const ports = [7000, 7001, 7002, 7003, 7004, 7005];

    // we create a new Docker network so that we have a consistent way
    // to retrieve the internal addresses of the Redis nodes to build
    // the NAT map
    network = await new Network().start();

    // "grokzen/redis-cluster" is the name of the Docker
    // image to download and run
    container = await new GenericContainer("grokzen/redis-cluster")
      // exposes each of the internal Docker ports listed
      // in `ports` to the host machine
      .withExposedPorts(...ports)
      .withNetworkMode(network.getName())
      .withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
      .start();

    const networkIpAddress = container.getIpAddress(network.getName());

    const dockerHost = container.getHost();
    const hosts = ports.map((port) => {
      // { host: "localhost", port: 55305 }
      return { host: dockerHost, port: container.getMappedPort(port) };
    });

    /**
     * {
     *   "192.168.16.2:7000": { host: "127.0.0.1", port: 55305 },
     *   "192.168.16.2:7001": { host: "127.0.0.1", port: 55306 },
     *   ...
     * }
     */
    const natMap = ports.reduce((map, port) => {
      const hostPort = container.getMappedPort(port);
      const internalAddress = `${networkIpAddress}:${port}`;
      map[internalAddress] = { host: dockerHost, port: hostPort };
      return map;
    }, {});

    redisClient = new Redis.Cluster(hosts, { natMap });
  });

  after(async () => {
    redisClient && (await redisClient.quit());
    container && (await container.stop());
    network && (await network.stop());
  });

  it("should set and retrieve values from the Redis cluster", async () => {
    await redisClient.set("key", "val");
    expect(await redisClient.get("key")).to.equal("val");
  });
});

Now that you are testing your applications with greater confidence using containers don't forget to let me know if you found this article helpful! Feel free to leave any questions/comments below, and checkout my other social media accounts!