FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

Testcontainers で DynamoDB のインテグレーションテスト環境を作る

この記事はフリューAdvent Calendar 2025の13日目の記事となります。

はじめに

フリュー株式会社で、ピクトリンクの開発に関わっている山根と言います。

今回は、DynamoDBを使用するTypeScriptアプリケーションのインテグレーションテストを、Testcontainers + DynamoDB Localで構築した方法を紹介します。

testcontainers.com

docs.aws.amazon.com

なぜモックではなくDynamoDB Localか

DynamoDBへのアクセスをモックに差し替えればユニットテストは可能ですが、ConditionExpressionやGSIを使ったクエリなど、実際のDynamoDBの挙動を検証することはできません。
DynamoDB Localを使えば、本番に近い環境でこれらの動作を確認できます。

なぜTestcontainersか

Docker Composeで事前にコンテナを起動しておく方法もありますが、Testcontainersを使うことで以下のメリットがあります。

  • テストコードだけで環境が完結する
  • ランダムポートが割り当てられるため、ポート競合を気にしなくていい
  • テスト終了時に自動でコンテナが破棄される

今回試した環境

動作環境

  • Node.js: 22.18.0
  • パッケージマネージャ(pnpm): 9.15.9
  • Docker: 利用(Testcontainersによるコンテナ起動に必要)

使用ライブラリ

ライブラリ バージョン
@aws-sdk/client-dynamodb 3.873.0
@aws-sdk/lib-dynamodb 3.873.0
testcontainers 11.5.1
vitest 3.2.4

DynamoDBクライアントの実装

今回テスト対象とするDynamoDBクライアントの実装例となります。

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

// Vitestの設定(vitest.config.ts)で NODE_ENV を 'test' に設定している
const isTest = process.env.NODE_ENV === 'test';

// テスト時に使用するDynamoDB Localへの接続設定
const localConfig = {
  endpoint: process.env.AWS_ENDPOINT_URL,
};

export const dynamoDBClient = new DynamoDBClient({
  region: process.env.AWS_REGION || 'ap-northeast-1',
  // テスト時のみローカル接続設定を適用
  ...(isTest && localConfig),
});

また、vitest.config.ts の設定例となります。

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    env: {
      NODE_ENV: 'test',
      // DynamoDB Localコンテナ内部のポート(ホスト側はランダムポートにマッピングされる)
      PORT: '8000',
      AWS_REGION: 'ap-northeast-1',
    },
    // Testcontainersのコンテナ起動に時間がかかるため、タイムアウトを長め(30秒)に設定
    testTimeout: 30_000,
  },
});

Testcontainersのセットアップ

DynamoDB Localコンテナの起動

では、Testcontainersを使ってDynamoDB Localコンテナを起動する実装となります。

import { CreateTableCommand, UpdateTimeToLiveCommand } from '@aws-sdk/client-dynamodb';
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  ScanCommand,
} from '@aws-sdk/lib-dynamodb';
import { GenericContainer, type StartedTestContainer } from 'testcontainers';

// テストコンテナとDynamoDBクライアントのインスタンスを保持する変数
let container: StartedTestContainer;
let client: DynamoDBDocumentClient;
// DynamoDB Localコンテナ内部のポート
export const port = Number(process.env.PORT) || 8000;

/**
 * DynamoDB Local コンテナを起動し、必要なテーブルを作成する
 */
export async function initDynamoDB() {
  // すでに初期化済みなら再利用
  if (container && client) return client;

  // DynamoDB Local コンテナを起動
  container = await new GenericContainer('amazon/dynamodb-local').withExposedPorts(port).start();
  const mappedPort = container.getMappedPort(port);

  // AWS_ENDPOINT_URL 環境変数を設定して、DynamoDB クライアントがローカルのエンドポイントを使用するようにする
  process.env.AWS_ENDPOINT_URL = `http://localhost:${mappedPort}`;

  // DynamoDBクライアントを取得(import先は環境に合わせて変更)
  const { dynamoDBClient } = await import('@/infrastructure/client/DynamoDBClient');
  client = DynamoDBDocumentClient.from(dynamoDBClient);

  // 必要なテーブルを作成
  await createTables();

  return client;
}

/**
 * DynamoDB Local コンテナを停止する
 */
export async function stopDynamoDB() {
  if (container) {
    await container.stop();
    container = undefined!;
    client = undefined!;
  }
}

テーブルの作成

テーブルを作成するサンプル関数となります。※先ほどのファイルに追記します。

/**
 * 必要なテーブルを作成する
 * ※ initDynamoDB() 内で client が初期化された後に呼び出される
 */
async function createTables() {
  // Usersテーブル(基本的なテーブル)
  await client.send(
    new CreateTableCommand({
      TableName: 'Users',
      AttributeDefinitions: [
        { AttributeName: 'userId', AttributeType: 'S' },
      ],
      KeySchema: [
        { AttributeName: 'userId', KeyType: 'HASH' },
      ],
      ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
    })
  );

  // Ordersテーブル(GSI + TTL付き)
  await client.send(
    new CreateTableCommand({
      TableName: 'Orders',
      AttributeDefinitions: [
        { AttributeName: 'userId', AttributeType: 'S' },
        { AttributeName: 'orderId', AttributeType: 'S' },
        { AttributeName: 'status', AttributeType: 'S' },
        { AttributeName: 'createdAt', AttributeType: 'S' },
      ],
      KeySchema: [
        { AttributeName: 'userId', KeyType: 'HASH' },
        { AttributeName: 'orderId', KeyType: 'RANGE' },
      ],
      GlobalSecondaryIndexes: [
        {
          IndexName: 'status-createdAt-index',
          KeySchema: [
            { AttributeName: 'status', KeyType: 'HASH' },
            { AttributeName: 'createdAt', KeyType: 'RANGE' },
          ],
          Projection: { ProjectionType: 'ALL' },
          ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
        },
      ],
      ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
    })
  );

  // OrdersテーブルにTTL設定を追加
  await client.send(
    new UpdateTimeToLiveCommand({
      TableName: 'Orders',
      TimeToLiveSpecification: {
        AttributeName: 'expireAt',
        Enabled: true,
      },
    })
  );
}

テストデータの管理

テーブルデータの削除(トランケート)

DynamoDBには TRUNCATE TABLE のようなコマンドがないため、全件Scanしてから各アイテムを削除する必要があります。削除にはプライマリキーが必要なので、テーブルごとにキー情報を保持しておきます。

テーブルデータを削除するサンプル関数となります。※先ほどのファイルに追記します。

// テーブルごとのプライマリキー設定
const tableKeyConfigs: Record<string, any> = {
  Users: ['userId'],
  Orders: ['userId', 'orderId'],
};

/**
 * 全テーブルのデータをクリア
 */
export async function truncateAllTables(): Promise<void> {
  const tableNames = Object.keys(tableKeyConfigs);

  for (const tableName of tableNames) {
    await truncateTableData(tableName);
  }
}

/**
 * 指定のテーブルのデータをクリア
 */
async function truncateTableData(tableName: string): Promise<void> {
  // テーブルの全アイテムを取得
  const scanResult = await client.send(new ScanCommand({ TableName: tableName }));

  if (!scanResult.Items || scanResult.Items.length === 0) {
    return;
  }

  // 各アイテムを削除
  for (const item of scanResult.Items) {
    const key = extractPrimaryKey(tableName, item);
    await client.send(
      new DeleteCommand({
        TableName: tableName,
        Key: key,
      })
    );
  }
}

/**
 * テーブル名からプライマリキーを抽出
 */
function extractPrimaryKey(tableName: string, item: Record<string, any>): Record<string, any> {
  const keyNames = tableKeyConfigs[tableName] || ['id'];
  const key: Record<string, any> = {};

  for (const keyName of keyNames) {
    key[keyName] = item[keyName];
  }

  return key;
}

テストデータの投入

テストデータを投入するサンプル関数となります。単一のアイテムでも配列でも受け付けられるようにしています。※先ほどのファイルに追記します。

/**
 * DynamoDBにデータをPutする
 */
export async function putItems(tableName: string, items: Record<string, any> | Record<string, any>[]): Promise<void> {
  // 配列でない場合は配列に変換
  const itemsArray = Array.isArray(items) ? items : [items];

  for (const item of itemsArray) {
    await client.send(
      new PutCommand({
        TableName: tableName,
        Item: item,
      })
    );
  }
}

テストコードの例

では、先程のTestcontainersのセットアップの実装をsetup.tsに置き、Ordersテーブルの検索のテストを書いてみます。

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { initDynamoDB, stopDynamoDB, truncateAllTables, putItems } from './setup';

describe('Ordersのテスト', () => {
  // テスト開始前にDynamoDB Localを起動
  beforeAll(async () => {
    await initDynamoDB();
  });

  // テスト終了後にコンテナを停止
  afterAll(async () => {
    await stopDynamoDB();
  });

  // 各テストの前にデータをクリア
  beforeEach(async () => {
    await truncateAllTables();
  });

  it('注文を取得できる', async () => {
    // テストデータを投入
    await putItems('Orders', {
      userId: 'user-001',
      orderId: 'order-001',
      status: 'pending',
      createdAt: '2025-01-01T00:00:00Z',
    });

    // テスト対象の処理を実行(実際のリポジトリやサービスを呼び出す)
    const result = await orderRepository.findByUserId('user-001');

    // 検証
    expect(result).toHaveLength(1);
    expect(result[0].orderId).toBe('order-001');
  });
});

まとめ

Testcontainers + DynamoDB Local を使ったインテグレーションテスト環境の構築方法を紹介しました。

今回の構成をあらためて整理すると、以下のメリットを感じることができました。

  • モックでは検証できないDynamoDBの実際の挙動をテストできる
  • Dockerが動く環境であれば、追加のセットアップなしでテストを実行できる
  • テストごとにクリーンな状態から始められる

特に、DynamoDB Localは本番環境と一部挙動が異なる点もありますが、ConditionExpressionやGSIのクエリなど、実際のDynamoDBに近い形でテストできるのは大きなメリットかと思います。

興味があればぜひ試してみてください。何かの参考になれば嬉しいです。