junyeokk
Blog
Testing·2025. 10. 29

Testcontainers

통합 테스트를 작성할 때 가장 골치 아픈 부분은 외부 의존성이다. MySQL, Redis, RabbitMQ 같은 인프라를 테스트 환경에서 어떻게 준비할 것인가? 크게 세 가지 접근이 있다.

  1. 공용 테스트 서버 — 팀원이 동시에 테스트하면 데이터가 꼬인다
  2. 목(Mock) 라이브러리 — 실제 동작과 차이가 있어서 "테스트는 통과하는데 프로덕션에서 터지는" 상황이 생긴다
  3. 로컬에 직접 설치 — 버전 맞추기가 번거롭고, CI 환경에서는 또 다른 설정이 필요하다

Testcontainers는 이 문제를 Docker 컨테이너로 해결한다. 테스트 코드 안에서 프로그래밍 방식으로 Docker 컨테이너를 띄우고, 테스트가 끝나면 자동으로 정리한다. 실제 MySQL에 쿼리를 날리고, 실제 Redis에 데이터를 넣고, 실제 RabbitMQ에 메시지를 발행하는 테스트를 작성할 수 있다.

핵심 개념

Testcontainers의 동작 원리는 단순하다.

  1. 테스트 시작 시 Docker 이미지를 기반으로 컨테이너를 생성
  2. 컨테이너가 준비될 때까지 대기 (health check)
  3. 랜덤 포트를 할당하고, 해당 포트 정보를 테스트 코드에 전달
  4. 테스트 실행
  5. 테스트 종료 시 컨테이너 자동 삭제

여기서 핵심은 랜덤 포트 할당이다. 고정 포트를 사용하면 다른 테스트나 로컬 서비스와 충돌할 수 있지만, 매번 랜덤 포트를 할당하면 충돌이 원천적으로 차단된다. 그래서 getPort(), getMappedPort() 같은 메서드로 할당된 포트를 동적으로 가져와야 한다.

설치

Node.js 환경에서는 testcontainers 패키지와 서비스별 모듈을 설치한다.

bash
npm install -D testcontainers @testcontainers/mysql @testcontainers/redis @testcontainers/rabbitmq

전제 조건은 Docker가 실행 중이어야 한다는 것이다. Testcontainers는 내부적으로 Docker API를 호출해서 컨테이너를 관리한다.

GenericContainer — 범용 컨테이너

Testcontainers에는 두 가지 컨테이너 타입이 있다. 하나는 GenericContainer로, 아무 Docker 이미지나 띄울 수 있는 범용 컨테이너다.

typescript
import { GenericContainer, StartedTestContainer } from 'testcontainers';

const container: StartedTestContainer = await new GenericContainer('nginx:latest')
  .withExposedPorts(80)
  .start();

const host = container.getHost();
const port = container.getMappedPort(80);

console.log(`http://${host}:${port}`); // http://localhost:49152 (랜덤 포트)

// 테스트 끝나면 정리
await container.stop();

withExposedPorts(80)은 컨테이너 내부의 80번 포트를 호스트의 랜덤 포트에 매핑하겠다는 뜻이다. getMappedPort(80)으로 실제 매핑된 호스트 포트를 가져온다.

GenericContainer는 공식 모듈이 없는 서비스를 테스트할 때 유용하다. 예를 들어 Mailpit(SMTP 테스트 서버) 같은 서비스를 띄울 수 있다.

typescript
const mailpit: StartedTestContainer = await new GenericContainer('axllent/mailpit:latest')
  .withExposedPorts(1025, 8025)
  .start();

const smtpHost = mailpit.getHost();
const smtpPort = mailpit.getMappedPort(1025);  // SMTP 포트
const uiPort = mailpit.getMappedPort(8025);    // 웹 UI 포트

여러 포트를 동시에 노출할 수도 있다. 각 포트마다 독립적인 랜덤 포트가 할당된다.

서비스별 모듈 — 타입 안전한 전용 컨테이너

자주 사용되는 서비스에는 전용 모듈이 있다. GenericContainer와 달리 해당 서비스에 특화된 메서드를 제공한다.

MySQL

typescript
import { MySqlContainer, StartedMySqlContainer } from '@testcontainers/mysql';

const container: StartedMySqlContainer = await new MySqlContainer('mysql:8.0')
  .start();

const host = container.getHost();
const port = container.getPort();
const database = container.getDatabase();
const username = container.getUsername();
const password = container.getUserPassword();
const rootPassword = container.getRootPassword();

MySqlContainer는 기본적으로 데이터베이스, 유저, 비밀번호를 자동으로 설정해준다. getDatabase(), getUsername() 같은 메서드로 생성된 정보를 가져올 수 있어서 하드코딩할 필요가 없다.

Redis

typescript
import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis';

const container: StartedRedisContainer = await new RedisContainer('redis:6.0-alpine')
  .withCommand(['redis-server', '--databases', '16'])
  .start();

const host = container.getHost();
const port = container.getPort();

withCommand()로 컨테이너 시작 시 실행할 커맨드를 지정할 수 있다. 위 예시에서는 Redis의 데이터베이스 수를 16개로 설정한다. 병렬 테스트에서 각 워커가 서로 다른 데이터베이스를 사용하도록 할 때 유용하다.

RabbitMQ

typescript
import { RabbitMQContainer, StartedRabbitMQContainer } from '@testcontainers/rabbitmq';

const container: StartedRabbitMQContainer = await new RabbitMQContainer('rabbitmq:4.1-management')
  .start();

const host = container.getHost();
const amqpPort = container.getMappedPort(5672);        // AMQP 포트
const managementPort = container.getMappedPort(15672);  // Management UI 포트

rabbitmq:4.1-management 이미지를 사용하면 Management UI도 함께 뜬다. 디버깅할 때 브라우저로 Management UI에 접속해서 큐 상태를 확인할 수 있다.

컨테이너 설정 메서드

컨테이너를 생성할 때 다양한 설정을 체이닝으로 적용할 수 있다.

withExposedPorts — 포트 노출

typescript
new GenericContainer('my-image')
  .withExposedPorts(8080, 9090)
  .start();

컨테이너 내부 포트를 호스트에 노출한다. 여러 포트를 동시에 지정할 수 있다.

withEnvironment — 환경 변수

typescript
new GenericContainer('my-image')
  .withEnvironment({
    NODE_ENV: 'test',
    LOG_LEVEL: 'debug',
  })
  .start();

컨테이너에 환경 변수를 주입한다. docker run -e 옵션과 동일하다.

withCopyFilesToContainer — 파일 복사

typescript
import * as path from 'path';

new RabbitMQContainer('rabbitmq:4.1-management')
  .withCopyFilesToContainer([
    {
      source: path.resolve(__dirname, 'definitions.json'),
      target: '/etc/rabbitmq/definitions.json',
    },
  ])
  .start();

호스트의 파일을 컨테이너 내부로 복사한다. RabbitMQ의 Exchange/Queue 정의 파일이나 MySQL의 초기화 SQL 파일을 넣을 때 사용한다. 컨테이너가 시작되기 전에 파일이 복사되므로, 초기 설정으로 사용하기에 적합하다.

withCommand — 실행 커맨드

typescript
new RedisContainer('redis:6.0-alpine')
  .withCommand(['redis-server', '--maxmemory', '256mb', '--databases', '32'])
  .start();

컨테이너의 기본 실행 커맨드를 오버라이드한다. docker run 뒤에 붙는 커맨드와 동일하다.

exec — 컨테이너 내 명령 실행

typescript
const container = await new RabbitMQContainer('rabbitmq:4.1-management').start();

await container.exec([
  'rabbitmqctl',
  'import_definitions',
  '/etc/rabbitmq/definitions.json',
]);

이미 실행 중인 컨테이너 안에서 명령을 실행한다. docker exec와 동일하다. 컨테이너가 시작된 후 추가 설정이 필요할 때 사용한다. 예를 들어 RabbitMQ에 Exchange와 Queue 정의를 import하거나, MySQL에 추가 데이터베이스를 생성하는 용도로 쓸 수 있다.

Jest Global Setup과 통합

Testcontainers를 가장 효과적으로 사용하는 방법은 Jest의 Global Setup에서 컨테이너를 한 번만 띄우고, 모든 테스트가 공유하는 것이다. 테스트 파일마다 컨테이너를 띄우면 시간이 너무 오래 걸린다.

typescript
// jest.global-setup.ts
import { MySqlContainer, StartedMySqlContainer } from '@testcontainers/mysql';
import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis';

let mysqlContainer: StartedMySqlContainer;
let redisContainer: StartedRedisContainer;

export default async () => {
  // 컨테이너를 병렬로 시작 — 순차보다 훨씬 빠르다
  [mysqlContainer, redisContainer] = await Promise.all([
    new MySqlContainer('mysql:8.0').start(),
    new RedisContainer('redis:6.0-alpine').start(),
  ]);

  // 환경 변수로 접속 정보를 전달
  process.env.DB_HOST = mysqlContainer.getHost();
  process.env.DB_PORT = mysqlContainer.getPort().toString();
  process.env.DB_NAME = mysqlContainer.getDatabase();
  process.env.DB_USER = mysqlContainer.getUsername();
  process.env.DB_PASSWORD = mysqlContainer.getUserPassword();

  process.env.REDIS_HOST = redisContainer.getHost();
  process.env.REDIS_PORT = redisContainer.getPort().toString();
};

Promise.all()로 여러 컨테이너를 동시에 시작하는 게 중요하다. MySQL은 시작하는 데 510초 걸리고, Redis는 12초, RabbitMQ는 35초 정도 걸린다. 순차적으로 시작하면 1017초지만, 병렬로 시작하면 가장 느린 MySQL 기준으로 5~10초면 끝난다.

환경 변수로 접속 정보를 전달하면 각 테스트 파일에서 process.env로 접속 정보를 읽어서 사용할 수 있다.

Global Teardown

typescript
// jest.global-teardown.ts
export default async () => {
  // Testcontainers가 자동으로 정리하지만, 명시적으로 중지하면 더 빠르다
  await Promise.all([
    globalThis.__MYSQL_CONTAINER__?.stop(),
    globalThis.__REDIS_CONTAINER__?.stop(),
  ]);
};

사실 Testcontainers는 Ryuk이라는 리소스 정리 컨테이너를 자동으로 띄워서, 테스트 프로세스가 비정상 종료되어도 컨테이너를 정리해준다. 하지만 명시적으로 stop()을 호출하면 더 빨리 정리된다.

병렬 테스트에서의 격리

Jest의 --maxWorkers 옵션으로 테스트를 병렬 실행하면, 여러 워커가 같은 데이터베이스에 접근하면서 데이터가 충돌할 수 있다. 이를 해결하는 방법 중 하나는 워커마다 별도의 데이터베이스를 사용하는 것이다.

typescript
import * as os from 'os';
import * as mysql from 'mysql2/promise';

const CPU_COUNT = os.cpus().length;
const MAX_WORKERS = Math.max(1, Math.floor(CPU_COUNT * 0.5));

const createTestDatabases = async (container: StartedMySqlContainer) => {
  const conn = await mysql.createConnection({
    host: container.getHost(),
    port: container.getPort(),
    user: 'root',
    password: container.getRootPassword(),
  });

  for (let i = 1; i <= MAX_WORKERS; i++) {
    await conn.query(`CREATE DATABASE IF NOT EXISTS my_app_test_${i}`);
    await conn.query(
      `GRANT ALL PRIVILEGES ON my_app_test_${i}.* TO '${container.getUsername()}'@'%'`
    );
  }

  await conn.query('FLUSH PRIVILEGES');
  await conn.end();
};

CPU 코어 수의 절반만큼 워커를 사용하고, 각 워커에 대응하는 테스트 데이터베이스를 미리 생성한다. 각 워커는 자신의 워커 ID에 해당하는 데이터베이스를 사용하므로 데이터가 겹치지 않는다.

Redis도 마찬가지로 워커별로 다른 데이터베이스 번호를 사용할 수 있다. Redis는 기본적으로 0~15번까지 16개의 논리적 데이터베이스를 지원하므로, 워커 수에 맞춰 --databases 옵션을 조절하면 된다.

GenericContainer의 활용 — SMTP 테스트

공식 모듈이 없는 서비스도 GenericContainer로 테스트할 수 있다. 이메일 발송 테스트가 대표적인 예다. 실제 SMTP 서버 대신 Mailpit 같은 가짜 SMTP 서버를 Docker로 띄워서 이메일이 제대로 발송되는지 검증한다.

typescript
import { GenericContainer, StartedTestContainer } from 'testcontainers';

const mailpit: StartedTestContainer = await new GenericContainer('axllent/mailpit:latest')
  .withExposedPorts(1025, 8025)
  .start();

// SMTP 접속 정보
process.env.SMTP_HOST = mailpit.getHost();
process.env.SMTP_PORT = mailpit.getMappedPort(1025).toString();

// Mailpit API로 수신된 이메일 확인
const apiUrl = `http://${mailpit.getHost()}:${mailpit.getMappedPort(8025)}`;

Mailpit은 SMTP 프로토콜을 구현하면서도 수신된 이메일을 REST API로 조회할 수 있는 테스트용 메일 서버다. 이메일 발송 후 API를 호출해서 실제로 이메일이 도착했는지, 제목과 본문이 올바른지 검증할 수 있다.

CI 환경에서의 사용

Testcontainers는 CI 환경에서도 잘 동작한다. 단, Docker가 사용 가능해야 한다. GitHub Actions에서는 기본적으로 Docker를 사용할 수 있으므로 별도 설정 없이 바로 쓸 수 있다.

yaml
# .github/workflows/test.yml
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

Docker-in-Docker가 필요하지 않다. Testcontainers는 호스트의 Docker 소켓(/var/run/docker.sock)을 직접 사용하기 때문이다. CI 러너에 Docker가 설치되어 있기만 하면 로컬과 동일하게 동작한다.

다만 CI에서는 Docker 이미지를 매번 pull하는 시간이 추가된다. 이미지 캐싱을 설정하면 이 시간을 줄일 수 있다.

주의할 점

시작 시간

컨테이너를 띄우는 데 시간이 걸린다. MySQL은 510초, Redis는 12초, RabbitMQ는 3~5초 정도다. 단위 테스트에는 적합하지 않고, 통합 테스트(E2E)에서 사용하는 게 맞다. 빠른 피드백이 필요한 단위 테스트는 목 라이브러리를 사용하고, 실제 동작을 검증해야 하는 통합 테스트에서 Testcontainers를 사용하는 전략이 효과적이다.

Docker 필수

Testcontainers는 Docker 없이는 동작하지 않는다. 로컬 개발 환경과 CI 모두에서 Docker가 설치되고 실행 중이어야 한다. Docker Desktop을 사용하는 macOS/Windows 환경에서는 Docker Desktop이 실행 중인지 확인해야 한다.

Ryuk 컨테이너

Testcontainers는 testcontainers/ryuk이라는 리소스 정리 전용 컨테이너를 자동으로 실행한다. 이 컨테이너가 테스트에서 생성한 컨테이너들을 감시하다가, 테스트 프로세스가 종료되면(정상이든 비정상이든) 모든 관련 컨테이너를 정리한다. 덕분에 테스트가 실패하거나 중간에 강제 종료되어도 고아 컨테이너가 남지 않는다.

포트 충돌 방지

앞서 언급한 대로, Testcontainers는 항상 랜덤 포트를 사용한다. 하드코딩된 포트(예: 3306, 6379)로 접속하면 안 된다. 반드시 getPort()getMappedPort()로 동적 할당된 포트를 사용해야 한다.

typescript
// ❌ 잘못된 방식
const redisClient = new Redis({ host: 'localhost', port: 6379 });

// ✅ 올바른 방식
const redisClient = new Redis({
  host: container.getHost(),
  port: container.getPort(),
});

Mock vs Testcontainers

기준Mock 라이브러리Testcontainers
속도밀리초 단위초 단위 (컨테이너 시작)
신뢰도낮음 (모의 동작)높음 (실제 서비스)
설정 복잡도낮음중간 (Docker 필요)
적합한 테스트단위 테스트통합/E2E 테스트
버그 발견 능력로직 버그만인프라 관련 버그도 발견

둘은 경쟁이 아니라 보완 관계다. 단위 테스트에서는 Mock으로 빠르게 로직을 검증하고, 통합 테스트에서는 Testcontainers로 실제 인프라와의 연동을 검증하는 이중 전략이 가장 효과적이다.

정리

  • 테스트 코드 안에서 Docker 컨테이너를 프로그래밍 방식으로 띄우고, 랜덤 포트 할당으로 충돌을 원천 차단한다
  • Global Setup에서 Promise.all()로 병렬 시작하고, 워커별 DB 분리로 병렬 테스트 격리를 확보한다
  • 단위 테스트는 Mock, 통합 테스트는 Testcontainers로 이중 전략을 구성하는 게 효과적이다

관련 문서