Skip to content
ExponaBLOG
NESTJS · DAY 053 / 100

Testcontainers for Node: NestJS best practices explained

Real Postgres and Redis in your tests, started from code.

Expona Admin9 min read

Introduction

Running your NestJS tests against real Postgres and Redis instances sounds like a luxury reserved for CI pipelines with unlimited resources. With Testcontainers for Node, you can spin up genuine database containers from your test code itself—no Docker Compose files, no manual setup, no mocking away the persistence layer. Testcontainers for Node: NestJS best practices explained covers how teams at scale maintain confidence in their integration tests by using real services locally and in CI with the same reproducible setup.

This is the bridge between unit tests that run in milliseconds and production deployments that fail silently. When your ORM query works in tests but breaks under real transaction isolation, you'll wish you'd started containers in a global setup months ago.

Why Testcontainers Matter for NestJS

Mocking database calls works until it doesn't. A test passes because you mocked the query to return exactly what you expect, but production receives slightly different column ordering, or transaction semantics differ, or a connection pool times out. Testcontainers eliminate that gap by provisioning real Postgres and Redis instances that run alongside your tests.

For NestJS teams, this means testing Prisma queries, TypeORM relationships, and Redis cache invalidation against actual engines. You catch bugs before deployment without slowing every single test run—you reserve Testcontainers for integration suites while keeping unit tests fast.

The setup cost is minimal: a few lines of configuration, and you've gained confidence that your ORM actually works the way you wrote it.

Start Containers in a Global Setup

Testcontainers for Node manages container lifecycle through Jest's global setup and teardown hooks. Rather than starting and stopping a container per test, you start once before all tests and tear down after they complete. This reduces overhead and mirrors how you'd run Postgres and Redis in development.

Here's a global setup file that both Postgres and Redis containers:

typescript
// test/global-setup.ts
import { PostgreSqlContainer, RedisContainer } from 'testcontainers';

const postgres = new PostgreSqlContainer()
  .withDatabase('test_db')
  .withUsername('testuser')
  .withUserPassword('testpass')
  .withExposedPorts(5432);

const redis = new RedisContainer()
  .withExposedPorts(6379);

module.exports = async () => {
  const startedPostgres = await postgres.start();
  const startedRedis = await redis.start();

  const pgHost = startedPostgres.getHost();
  const pgPort = startedPostgres.getFirstMappedPort();
  const redisHost = startedRedis.getHost();
  const redisPort = startedRedis.getFirstMappedPort();

  // Write connection strings to environment for tests
  process.env.DATABASE_URL = `postgresql://testuser:testpass@${pgHost}:${pgPort}/test_db`;
  process.env.REDIS_URL = `redis://${redisHost}:${redisPort}`;

  // Store references for teardown
  global.__POSTGRES__ = startedPostgres;
  global.__REDIS__ = startedRedis;
};

And a corresponding teardown that stops both containers:

typescript
// test/global-teardown.ts
module.exports = async () => {
  if (global.__POSTGRES__) {
    await global.__POSTGRES__.stop();
  }
  if (global.__REDIS__) {
    await global.__REDIS__.stop();
  }
};

Update your Jest configuration to reference these:

typescript
// jest.config.js
export default {
  testEnvironment: 'node',
  globalSetup: './test/global-setup.ts',
  globalTeardown: './test/global-teardown.ts',
  testMatch: ['**/__tests__/**/*.spec.ts'],
};

With this structure, containers start once before all tests and stop after completion. Your test files receive real connection strings via environment variables, identical to local development and CI.

Run Prisma Migrations Against Them

Starting a real Postgres container is only half the work. You also need your schema—tables, indexes, foreign keys—present before tests run. Prisma migrations are perfect for this: they're version-controlled, reversible, and identical whether you run them locally or in CI.

In your global setup, run migrations after Postgres is ready:

typescript
// test/global-setup.ts
import { PostgreSqlContainer } from 'testcontainers';
import { execSync } from 'child_process';

const postgres = new PostgreSqlContainer()
  .withDatabase('test_db')
  .withUsername('testuser')
  .withUserPassword('testpass');

module.exports = async () => {
  const startedPostgres = await postgres.start();

  const pgHost = startedPostgres.getHost();
  const pgPort = startedPostgres.getFirstMappedPort();

  process.env.DATABASE_URL = `postgresql://testuser:testpass@${pgHost}:${pgPort}/test_db`;

  // Run Prisma migrations against the test database
  try {
    execSync('npx prisma migrate deploy', { 
      env: { ...process.env },
      stdio: 'inherit'
    });
  } catch (error) {
    console.error('Migration failed:', error);
    await startedPostgres.stop();
    throw error;
  }

  global.__POSTGRES__ = startedPostgres;
};

This approach guarantees your test environment schema matches your migrations. If a migration fails, the test suite fails before any tests run—you catch schema issues immediately. Teams often pair this with a seed script to populate reference data:

typescript
// test/global-setup.ts (continued)
import { PrismaClient } from '@prisma/client';

// After migrations run
const prisma = new PrismaClient();

try {
  // Seed reference data
  await prisma.role.createMany({
    data: [
      { id: 1, name: 'ADMIN' },
      { id: 2, name: 'USER' },
    ],
    skipDuplicates: true,
  });
} finally {
  await prisma.$disconnect();
}

Now every integration test starts with a clean schema and consistent reference data. No flaky tests due to missing columns or stale seeds.

Same Tests Locally and in CI

The beauty of Testcontainers is reproducibility. The exact same test suite runs on your machine, in GitHub Actions, and in any CI provider—same containers, same versions, same behavior. No "works on my machine" mysteries.

Here's what a typical integration test looks like in your NestJS module:

typescript
// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from './users.service';

describe('UsersService (integration)', () => {
  let service: UsersService;
  let prisma: PrismaService;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService, PrismaService],
    }).compile();

    service = module.get(UsersService);
    prisma = module.get(PrismaService);
  });

  afterEach(async () => {
    // Clean up test data between tests
    await prisma.user.deleteMany();
  });

  it('should create a user and retrieve it', async () => {
    const user = await service.create({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(user.id).toBeDefined();
    expect(user.email).toBe('test@example.com');

    const retrieved = await service.findById(user.id);
    expect(retrieved).toMatchObject({
      email: 'test@example.com',
      name: 'Test User',
    });
  });

  it('should enforce unique email constraint', async () => {
    await service.create({
      email: 'duplicate@example.com',
      name: 'First User',
    });

    await expect(
      service.create({
        email: 'duplicate@example.com',
        name: 'Second User',
      })
    ).rejects.toThrow('Unique constraint failed');
  });

  it('should handle transaction rollback on error', async () => {
    const initialCount = await prisma.user.count();

    try {
      await service.createWithValidation({
        email: 'invalid-email',
        name: 'Test',
      });
    } catch (error) {
      // Expected
    }

    const finalCount = await prisma.user.count();
    expect(finalCount).toBe(initialCount); // Transaction rolled back
  });
});

This test file runs identically locally and in CI because it connects to a real Postgres instance via environment variables set by your global setup. No mocking, no stubbing—real ORM behavior, real constraints, real transactions.

In your CI pipeline (GitHub Actions example):

typescript
// .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:integration

The CI runner has Docker available (standard for ubuntu-latest), so Testcontainers starts Postgres and Redis just like on your machine. No additional setup needed.

Slower Than Mocks — Reserve for Integration Suites

Testcontainers add latency. Starting a Postgres container takes seconds. Running a query against a real database is slower than returning a mock. For fast feedback during development, you still need unit tests with mocked services.

A pragmatic structure separates concerns:

Test Type Speed Scope Tools
Unit ~1-10ms Single function, mocked deps Jest + mock repository
Integration ~50-500ms Service + Postgres/Redis Jest + Testcontainers
E2E ~500ms-5s Full HTTP stack Supertest + Testcontainers

In your NestJS project, organize tests accordingly:

typescript
// src/users/users.service.unit.spec.ts
describe('UsersService (unit)', () => {
  let service: UsersService;
  let mockRepository: Mock;

  beforeEach(() => {
    mockRepository = {
      findById: jest.fn(),
      create: jest.fn(),
    };
    service = new UsersService(mockRepository);
  });

  it('should return formatted user from repository', () => {
    mockRepository.findById.mockResolvedValue({ 
      id: 1, 
      email: 'test@example.com' 
    });

    const result = service.getUserDTO(1);

    expect(result).toEqual({
      id: 1,
      email: 'test@example.com',
      displayName: 'test@example.com'
    });
  });
});

Unit tests run in milliseconds. Integration tests (the .spec.ts files from earlier) verify that Prisma queries actually work. E2E tests confirm HTTP routing. Run all unit tests on every save during development; run integration tests before committing; run everything in CI.

This balance keeps your development loop fast while preventing real bugs from reaching production.

Common Mistakes

Not cleaning up test data: If tests don't reset tables between runs, later tests fail because of stale data. Always include an afterEach cleanup step or use database transactions that roll back after each test.

Sharing a single Testcontainers instance across different test files without isolation: Multiple test suites writing to the same Postgres instance can interfere. Use a test database per run, or wrap each test file in a transaction that rolls back.

Starting containers per test instead of globally: This defeats the purpose. Start once in global setup; stop once in global teardown. Starting per test multiplies your runtime.

Forgetting to set DATABASE_URL in CI: If your CI doesn't inherit the environment variable from global setup, Testcontainers containers start but your code connects elsewhere. Always verify process.env.DATABASE_URL points to the right host and port.

Using production container images in tests: If your Postgres image includes production-specific extensions or configuration, tests behave differently than CI. Stick to standard, publicly available images like postgres:15 and redis:7.

Key Takeaways

  • Testcontainers for Node eliminates the mock-vs-reality gap by running real Postgres and Redis in tests, identical to production.
  • Start containers once in Jest global setup, run migrations, and tear down after all tests complete to minimize overhead.
  • The same test file runs locally and in CI because connection strings come from environment variables set by your global setup.
  • Integration tests are slower than unit tests; keep mocked unit tests for fast feedback and reserve Testcontainers for integration and E2E suites.
  • Always clean test data between test runs and verify container images match your production versions.
  • Real schema enforcement, transaction semantics, and query behavior in tests catch bugs that mocks miss.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles