Testcontainersでコンテナ間テストを試したら良かったと感じた話

はじめに
こんにちは、ソリューション事業部の斉藤です。
最近は自社プロダクト開発に携わるようになりました。
日々、品質を高めたい、継続的に品質を守りながらプロダクトを更新したい、と開発しています。
その中で「プロダクトのコンテナ間テストができないだろうか」と考え始めました。
コンテナ間のテストがしたい
プロダクトのコンテナとはいわゆるマイクロサービス一つ一つのことを指していまして、次のようなものが挙げられます。
- フロントエンドコンテナ
- バックエンドコンテナ
- DBコンテナ
- バッチコンテナ
今までは、それぞれのコンテナ内でユニットテストが書けているものの、コンテナを繋ぎ合わせた時の動きは手動で確認していました。
これを自動化したい、というモチベーションがあります。
例えば次のようなシナリオです。
- バッチコンテナが外部APIを実行し、データを取得する
- 1で取得したデータを、バッチコンテナがバックエンドコンテナの登録APIに送信する
- 2で入ってきたデータを、バックエンドコンテナがDBコンテナに入れる
(バッチコンテナがDBに直接登録、という話もあろうかとは思いますが、プロダクトの性質上このシナリオとなりました。)
要は「バッチが取得したデータは、それぞれのコンテナを経由してDBに意図通りに入ったか?」をテストしたい、ということですね。
そこで採用したのが、Testcontainersという技術です。
Testcontainers
Dockerを使って一時的なコンテナを立ち上げることができるツールです。
特に「一時的」というのがコア技術だそうで、Ryukというクリーンアップ用コンテナによって、使い終わったコンテナ、ボリューム、ネットワーク、イメージを削除する機能が入っています。
(Ryukという名前は、漫画デスノートに出てくる死神リュークに因んでいるそうです)
この技術を使ってテストを書くことにしました。
使用した環境は以下の通りです。
- Testcontainers@10.28.0
- Vitest@2.1.9
- シナリオとテストコードを実行する環境として利用
- pg@8.20.0
- プロダクトのバッチコンテナイメージ
- プロダクトのバックエンドコンテナイメージ
- プロダクトのDBコンテナイメージ
以下、サンプルとして検証した内容を示します。
検証した内容
コンテナ内のDockerfileやアプリソースコードを全て示すのは冗長になるため、以下のみ記載します。
- シーケンス図
- テストしたいシナリオ含む、全体の流れを表現
- コンテナのインターフェイス
- コンテナのリクエスト、レスポンスを表現
- compose.test.yaml
- docker compose コマンドで立ち上げたい、コンテナの内容
- batch.integration.test.ts
- テストしたいシナリオ含む、全体の流れをTestcontainersを使った記載
シーケンス図

コンテナのインターフェイス
上記シーケンス図内の番号に対応する、コンテナのインターフェイスを示します。
4, 5 外部APIからユーザー一覧を取得する
GET /users
Response:
[
{
"email": "yamada@example.com",
"displayName": "山田 太郎",
"department": "A課"
},
{
"email": "suzuki@example.com",
"displayName": "鈴木 花子",
"department": "B課"
}
]6, 9 取得したユーザーを登録APIに送信する
サンプルのため、4,5のインターフェイスと同じにしました。
POST /users
Request Payload:
[
{
"email": "yamada@example.com",
"displayName": "山田 太郎",
"department": "A課"
},
{
"email": "suzuki@example.com",
"displayName": "鈴木 花子",
"department": "B課"
}
]10. 検証に利用するSQL
SELECT u.email, u.display_name, d.name AS department
FROM users u
JOIN departments d ON u.department_id = d.id
WHERE u.email = '...';compose.test.yaml
サンプルのため、外部APIはmockで表現しています。
services:
# DB
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
TZ: Asia/Tokyo
volumes:
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
interval: 2s
start_period: 5s
# 外部API モック
external-api-mock:
build:
context: ./external-api-mock
environment:
TZ: Asia/Tokyo
PORT: "3000"
ports:
- "3000"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/users"]
interval: 2s
# バックエンドAPI
api:
build:
context: ./api
environment:
TZ: Asia/Tokyo
PORT: "3000"
DATABASE_URL: postgresql://testuser:testpass@db:5432/testdb
ports:
- "3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 2s
# バッチ
# バッチはジョブ実行型のため `healthcheck` を定義せず、Testcontainers の `Wait.forLogMessage` で完了ログを検知して次の処理に進みます
batch:
build:
context: ./batch
environment:
TZ: Asia/Tokyo
EXTERNAL_API_URL: http://external-api-mock:3000
API_BASE_URL: http://api:3000
depends_on:
api:
condition: service_healthy
external-api-mock:
condition: service_healthybatch.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
DockerComposeEnvironment,
Wait,
type StartedDockerComposeEnvironment,
} from "testcontainers";
import { Pool } from "pg";
let environment: StartedDockerComposeEnvironment;
let pool: Pool;
beforeAll(async () => {
// 1. Testcontainers が Docker Compose でコンテナを起動
// 2. テストしたいシナリオを実行(バッチコンテナ起動時に自動的にシナリオが実行される)
// `withBuild()` は `docker compose up --build` に相当します。最新のイメージからコンテナを作成したいため、毎回ビルドを行っています。
environment = await new DockerComposeEnvironment(".", "compose.test.yaml")
.withBuild()
.withWaitStrategy("batch-1", Wait.forLogMessage(/sync completed/))
.up();
const dbContainer = environment.getContainer("db-1");
const dbPort = dbContainer.getMappedPort(5432);
pool = new Pool({
connectionString: `postgresql://testuser:testpass@localhost:${dbPort}/testdb`,
});
});
afterAll(async () => {
await pool?.end();
await environment?.down();
});
// 3. テストコードを実行
describe("バッチ実行後のDB検証", () => {
it.each([
{ email: "yamada@example.com", displayName: "山田 太郎", department: "A課" },
{ email: "suzuki@example.com", displayName: "鈴木 花子", department: "B課" },
])(
"$email のユーザーが登録されていることを確認",
async ({ email, displayName, department }) => {
const result = await pool.query(
`SELECT u.email, u.display_name, d.name AS department
FROM users u
JOIN departments d ON u.department_id = d.id
WHERE u.email = $1`,
[email],
);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].display_name).toBe(displayName);
expect(result.rows[0].department).toBe(department);
},
);
});このテストコードを実行すると以下のような結果が表示されます。
コマンド一つでシナリオを実行し、DBに入った実際の値と期待値を比較したテストが実行できています。
% npx vitest run
RUN v2.1.9 /path/to/blog-2026-04
✓ tests/integration/batch.integration.test.ts (2) 45656ms
✓ バッチ実行後のDB検証 (2)
✓ 'yamada@example.com' のユーザーが登録されていることを確認
✓ 'suzuki@example.com' のユーザーが登録されていることを確認
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 17:01:54
Duration 46.20s (transform 17ms, setup 0ms, collect 287ms, tests 45.66s, environment 0ms, prepare 39ms)まとめ
今回のアイデアを試して、以下のようなメリットデメリットを感じました。
メリット
- 以下のようなコンテナ間のつながりを実際にテストできる
- バッチがバックエンドのAPIを実行
- APIがDBにデータを登録
- もちろん人の手で検証するが、その範囲、回数を減らすことができる
- どのような形式でDBにデータが入っていれば良いかをテストケースに記述できるため、コンテナに足りない実装を洗い出すことができる
デメリット
- コンテナイメージが更新されることもあるため、イメージからのビルドを行い時間がかかる
- 本記事の例では約46秒かかりました
- テスト毎にDBを構築するため、テーブル定義やデータ投入などお膳立てが必要
- 本記事では省いていますが、DBが構築される際に、create tableが実行されるようにしています
記載の通りのデメリットはありましたが、それを差し引いてもこのメリットは心の拠り所としては申し分ありませんでした。
「他の人の手によって、いくらバックエンドコンテナが更新されても、提供したいシナリオは壊れていないこと」をコマンド一つで確かめることができることを検証でき、とても便利だと実感しました。
プロダクト開発は大変ですが、これがなかったらもっと大変だった、もっと確認の工数がかかっていたかと思うと、大げさですが 本当に助けられたな、と感じました。
皆さんのプロダクトにも利用できるところがあれば幸いです。






