DEV Community

Cover image for Building Confidence with Testcontainers
JoLo
JoLo

Posted on

Building Confidence with Testcontainers

Modern Node.js applications rarely live in isolation. They depend on databases, caches, message brokers, and third-party APIs. Unit tests validate business logic, but they cannot guarantee everything will still work once your service runs in a real environment with real dependencies.

This is where Testcontainers comes in: it lets you run integration tests against real services inside disposable Docker containers, both locally and in CI.

The Classic Deployment Failure Scenario

Problem Story
Story generated with Nano Banana 2

A familiar story:

  • You implement a new feature.
  • Your unit tests are green.
  • CI passes, you deploy, and everything looks good.
  • Shortly after, alerts start firing and messages arrive: requests are failing, the database can’t be reached, or some external service integration is broken.

Looking into the logs, you discover that your service failed to connect to another service—maybe a database, maybe a cache, maybe a message broker. The root cause is often that integration paths were never validated end-to-end. The code was tested in isolation, but the system was not.

In modern architectures—microservices and distributed systems—each service is just a small cog in a much larger machine. Services communicate over HTTP, gRPC, queues, and streams. If we only test in isolation with mocks, we ignore the very thing that often breaks in production: the integrations.

Why Integration Tests Are Hard

Integration tests are traditionally considered “hard” because:

  • They require external infrastructure, such as databases, brokers, or cloud services.
  • Managing these dependencies locally is painful (installing and configuring Postgres, Redis, Kafka, etc.).
  • Shared staging/sandbox environments are fragile and rarely reliably mirror production.
  • Cleaning up after tests is error-prone and often left to manual work.

A common workaround is to deploy to a sandbox or test environment and run manual or partially automated tests there. But these environments can drift from production and are often shared between teams, leading to flaky tests and fragile pipelines.

What Testcontainers Solves

Testcontainers addresses these pain points by wrapping real services inside lightweight, disposable Docker containers, managed directly from your tests.

At a high level, Testcontainers:

  • Starts a real service (for example, PostgreSQL or Redis) inside a container before your tests run.
  • Exposes connection details (host, port, credentials) to your test code.
  • Runs your tests against this real instance.
  • Automatically tears down the container afterwards.

This gives you:

  • Real services instead of mocks.
  • Isolated infrastructure per test suite (or even per test).
  • Repeatable, production-like testing both locally and in CI. ## Testcontainers vs. Docker Compose

It might be tempting to reach for Docker Compose for integration tests, but there are several drawbacks:

  • Port conflicts: Different projects or versions of the same service (e.g., PostgreSQL 8 vs 16) can’t share the same host port.
  • Networking complexity: You need to understand Docker’s networking model and set it up correctly.
  • Manual lifecycle management: It’s easy to start services in detached mode and forget to stop or remove them, leaving containers and volumes around.

Testcontainers abstracts all of this:

  • It picks free host ports automatically.
  • It hides Docker networking details behind a simple API.
  • It ensures containers are cleaned up when tests complete.

Instead of managing YAML files and Docker commands, you work entirely in code.

Example: Node.js (Bun) + Redis

Consider a simple Node.js/Bun application that uses Redis for caching. The application provides two functions: one to set a value in the cache and one to read it.

Application code (simplified):

import { createClient } from 'redis';

export async function setCache(redis: any, key: string, value: string) {
  await redis.set(key, value);
}

export async function getCache(redis: any, key: string) {
  return await redis.get(key);
}
Enter fullscreen mode Exit fullscreen mode

Without Testcontainers, you would either:

  • Install Redis locally and assume it’s running on a given port, or
  • Mock Redis entirely and never hit a real instance.

With Testcontainers, you can spin up a real Redis container just for your tests.
Integration test with Testcontainers and Bun's Redisclient:

import { RedisClient } from "bun";
import { describe, beforeAll, afterAll, it, expect } from 'bun:test'
import { RedisContainer, StartedRedisContainer } from "@testcontainers/redis";
import { getRedis, setRedis } from ".";

describe("Redis", () => {
  let container: StartedRedisContainer;
  let redisClient: RedisClient;

  beforeAll(async () => {
    container = await new RedisContainer("redis:8").start();
    const connectionUrl = container.getConnectionUrl();

    redisClient = new RedisClient(connectionUrl);
    await redisClient.connect();
  }, 30000);

  afterAll(async () => {
    if (redisClient) {
      redisClient.close();
    }
    if (container) {
      await container.stop();
    }
  });

  it("works", async () => {
    await setRedis("key", "val", redisClient);
    const cache = await getRedis("key", redisClient);
    expect(cache).toBe("val");
  });
});
Enter fullscreen mode Exit fullscreen mode

This test:

  • Starts a Redis container dynamically.
  • Connects to it using the mapped host and port.
  • Runs a real end-to-end operation (set + get).
  • Cleans up everything afterwards.

No manual Docker commands, no global Redis install, no mock behavior hiding integration issues.
How it works
How Testcontainers work

The good thing is you can easily swap the docker images out. So instead of using redis, you could use the open source module valkey 👇

import { RedisClient } from "bun";
import { describe, beforeAll, afterAll, it, expect } from 'bun:test'
import { ValkeyContainer as RedisContainer, StartedValkeyContainer as StartedRedisContainer } from "@testcontainers/valkey";
import { getRedis, setRedis } from ".";

describe("Redis", () => {
  let container: StartedRedisContainer;
  let redisClient: RedisClient;

  beforeAll(async () => {
    container = await new RedisContainer("valkey/valkey").start();
    const connectionUrl = container.getConnectionUrl();

    redisClient = new RedisClient(connectionUrl);
    await redisClient.connect();
  }, 30000);

  afterAll(async () => {
    if (redisClient) {
      redisClient.close();
    }
    if (container) {
      await container.stop();
    }
  });

  it("works", async () => {
    await setRedis("key", "val", redisClient);
    const cache = await getRedis("key", redisClient);
    expect(cache).toBe("val");
  });
});
Enter fullscreen mode Exit fullscreen mode

And you will notice the logic did not change.

Running Testcontainers in CI/CD

Testcontainers can be used in CI environments as long as Docker is available. Typical setup:

  • Ensure the CI runner has Docker installed and the test process has permission to talk to the Docker daemon.
  • Run your test suite as usual (npm testbun testpnpm test, etc.).
  • Testcontainers spins up and tears down containers on the fly.

This brings the same level of integration testing you have locally into your automated pipeline, giving you much higher confidence before each deployment.

When to Use Testcontainers

Testcontainers is particularly useful when:

  • Your service talks to databases, caches, or message brokers and you want to test those interactions realistically.
  • You want to avoid maintaining dedicated integration environments.
  • You want reproducible tests with self-contained infrastructure, both locally and in CI.

It doesn’t replace unit tests, but complements them by validating that the whole system behaves correctly when real dependencies are involved.

Go check it out.

Top comments (0)