DynamoDB local使ったローカル開発・テスト環境を構築してみた

はじめに

開発中のシステムでAmazon DynamoDBを使用しているのですが、ローカル環境でのテストやデバッグの際に毎回AWSの開発環境に接続するのは手間がかかりますし、テストデータの管理も煩雑になります。

そこで今回は、AWSが公式に提供しているDynamoDBのローカルエミュレータ「DynamoDB local」を使って、ローカル環境でDynamoDBの開発・テストを行う方法を試してみた備忘録となります。

本記事では以下の2つの使い方を試しています。

  • Docker Composeを使ったローカル開発環境の構築(API起動 + データ閲覧)
  • Testcontainersを使った自動テスト環境の構築

サンプルプロジェクトはSpring Boot + Java + Gradleの構成で、書籍管理のAPIを題材にしています。

参考リンク

DynamoDBのテストについて

DynamoDBのローカルでのテスト方法をいくつかピックアップしてみました。

方式Docker特徴
Docker Compose + DynamoDB localローカル開発向き。本番と同じAPIで動作し、データの永続化も可能
Testcontainers + DynamoDB local自動テスト向き。本番と同じAPIで動作し、テスト実行時にコンテナを自動起動・破棄
Testcontainers + LocalStackDynamoDB以外のAWSサービス(SQS, S3等)もまとめてテスト可能。一部挙動が異なる場合がある
モック(Mockito等)不要高速だが、実際のDynamoDB APIを呼ばないため本番との互換性は検証できない

DynamoDB localとは

DynamoDB localはAWSが公式に提供しているDynamoDBのローカルエミュレータです。Dockerイメージ(amazon/dynamodb-local)として配布されており、Dockerさえあればローカル環境でDynamoDBと同等のAPIを利用できます。

Testcontainersとは

Testcontainersは、テスト実行時にDockerコンテナのライフサイクル(起動・破棄)を自動管理してくれるOSSのライブラリです。AWS公式ではありませんが、Spring Boot 3.1以降で公式にサポートされています。DynamoDB local以外にも様々なDockerイメージと組み合わせて使用できます。

Docker Composeとの使い分けは主に以下の通りです。

Docker ComposeTestcontainers
用途ローカル開発自動テスト
コンテナ起動手動起動(docker-compose up)テスト実行時に自動起動
コンテナ停止手動停止(docker-compose down)テスト終了時に自動破棄
ポート固定(8000)ランダム(競合しない)
データ永続化可能テスト終了で消失

両者は別のDockerコンテナとして起動するため、テスト実行がローカル開発用のデータに影響することはありません。

LocalStackとは

LocalStackはAWSの各種サービスをローカルでエミュレートするOSSのAWSエミュレータです。DynamoDBだけでなくSQS、S3、SNSなど多くのAWSサービスをサポートしており、これらの機能をまとめてテストしたい場合に有力な選択肢となります。
有償のPro版もあり、より多くのAWSサービスのAPIや高度な機能をサポートするみたいです。
今回の検証ではDynamoDB単体のテストが目的のため対象外としました。

サンプルプロジェクトの構成

今回はDynamoDB localをDocker Compose(ローカル開発用)とTestcontainers(自動テスト用)の2通りで使用します。
Spring Boot + Java + Gradleで書籍管理のCRUD APIを作成しました。プロジェクト構成は以下の通りです。

dynamodb-local-sample/
├── build.gradle.kts
├── docker-compose.yml                  ← ローカル開発用
├── src/main/java/com/example/dynamodbsample/
│   ├── Application.java
│   ├── config/DynamoDbConfig.java      ← DynamoDB接続設定
│   ├── model/Book.java                 ← モデル
│   ├── dao/BookMapper.java             ← CRUD操作
│   ├── controller/BookController.java  ← REST API
│   └── init/DynamoDbTableInitializer.java  ← テーブル自動作成
├── src/main/resources/
│   ├── application.yaml                ← デフォルト設定
│   └── application-local.yaml          ← ローカル開発用設定
└── src/test/java/com/example/dynamodbsample/
    ├── DynamoDbTestConfig.java         ← Testcontainers設定
    ├── DynamoDbTableHelper.java        ← テスト用ヘルパー
    └── dao/BookMapperTest.java         ← テストクラス

このあと、DynamoDB localに関連するファイルについて説明していきます。
※DynamoDB localに関係無いコントローラやロジックについては説明を省略します。

ローカル開発環境の構築(Docker Compose)

まずはDocker Composeを使って、ローカルでDynamoDB localを起動し、Spring BootのAPIからアクセスできるようにします。

docker-compose.yml

services:
  dynamodb-local:
    image: amazon/dynamodb-local:latest
    command: "-jar DynamoDBLocal.jar -sharedDb -inMemory"
    ports:
      - "8000:8000"

-inMemoryオプションを指定すると、データはメモリ上にのみ保持され、コンテナを停止するとデータが消えます。
オプションを変更することでデータ永続化の設定をすることも可能です。

Spring Bootの接続設定

Spring Bootのプロファイル機能を使って、ローカル開発時はDynamoDB localに、本番・STG環境では実際のAWS DynamoDBに接続するよう切り替えます。

application.yaml(デフォルト設定)

aws:
  credentials:
    access-key: "※実際のAWS環境のaccess-keyを設定"
    secret-key: "※実際のAWS環境のsecret-keyを設定"
  region: "※実際のAWS環境のregionを設定"

application-local.yaml(ローカル開発用)

aws:
  dynamodb-local:
    endpoint: http://localhost:8000
  credentials:
    access-key: dummy
    secret-key: dummy

DynamoDbConfig.javaでは、dynamodb-localのエンドポイントが指定されている場合にローカル接続に切り替えています。
※このサンプルでは逆に実際のDynamoDB環境へ接続しての動作確認を行っていないため、実装が不足しているかもしれないです。

@Configuration
public class DynamoDbConfig {

    @Value("${aws.dynamodb-local.endpoint:}")
    private String endpoint;

    @Value("${aws.credentials.access-key:}")
    private String accessKey;

    @Value("${aws.credentials.secret-key:}")
    private String secretKey;

    @Value("${aws.region:ap-northeast-1}")
    private String region;

    @Bean
    public DynamoDbClient dynamoDbClient() {
        var builder = DynamoDbClient.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(
                        AwsBasicCredentials.create(accessKey, secretKey)));

        // DynamoDB localのエンドポイントが指定されている場合はローカル環境へ接続
        if (endpoint != null && !endpoint.isEmpty()) {
            builder.endpointOverride(URI.create(endpoint));
        }

        return builder.build();
    }
}

この設定によりdynamoDBの接続先がDynamoDB localに切り替わるため、あとは通常通りDynamoDBを使用するロジックを実装すればOKです。
(今回は説明を省略します)

起動手順

# 1. DynamoDB local 起動
docker-compose up -d

# 2. API起動
gradlew bootRun --args="--spring.profiles.active=local"

起動後、APIを実行するとAWS環境のDynamoDBではなく、ローカル環境のDynamoDB localにアクセスしてデータの登録や取得が実行されます。

GUIでのデータ閲覧(NoSQL Workbench)

DynamoDB localに登録したデータをGUIで確認したい場合は、AWSが公式に提供しているNoSQL Workbenchが便利です。無料で利用でき、テーブルの閲覧・データ編集・クエリ実行などが可能です。

自動テスト環境の構築(Testcontainers)

次に、Testcontainersを使って自動テストでDynamoDB localを利用する方法についてです。

依存関係の追加

build.gradle.ktsにTestcontainersの依存を追加します。

dependencies {
    // Testcontainers
    testImplementation("org.testcontainers:testcontainers:2.0.4")
    testImplementation("org.testcontainers:junit-jupiter:1.21.4")
}

テスト用のDynamoDB設定

テスト時にDynamoDB localのDockerコンテナを自動起動し、そこに接続するDynamoDbClientを提供する設定クラスを作成します。

/**
 * Testcontainers + DynamoDB Local のテスト用設定。
 * テスト時に自動でDockerコンテナを起動し、テスト用のDynamoDBクライアントを提供する。
 */
@TestConfiguration
public class DynamoDbTestConfig {

    // DynamoDB Local のDockerコンテナ定義
    private static final GenericContainer DYNAMO_DB_CONTAINER =
            new GenericContainer<>(DockerImageName.parse("amazon/dynamodb-local:latest"))
                    .withExposedPorts(8000)
                    .withCommand("-jar DynamoDBLocal.jar -inMemory -sharedDb");

    // クラスロード時にコンテナを起動(テストクラス間で使い回す)
    static {
        DYNAMO_DB_CONTAINER.start();
    }

    @Bean(destroyMethod = "close")
    @Primary
    public DynamoDbClient dynamoDbClient() {
        // Testcontainersが割り当てたランダムポートでエンドポイントを構築
        String endpoint = "http://" + DYNAMO_DB_CONTAINER.getHost()
                + ":" + DYNAMO_DB_CONTAINER.getFirstMappedPort();

        // DynamoDB Localへの接続のため、認証情報はダミー値でOK
        return DynamoDbClient.builder()
                .endpointOverride(URI.create(endpoint))
                .region(Region.AP_NORTHEAST_1)
                .credentialsProvider(StaticCredentialsProvider.create(
                        AwsBasicCredentials.create("dummy", "dummy")))
                .build();
    }

    @Bean
    @Primary
    public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
        return DynamoDbEnhancedClient.builder()
                .dynamoDbClient(dynamoDbClient)
                .build();
    }
}

getFirstMappedPort()でTestcontainersがランダムに割り当てたポートを取得してエンドポイントを構築するため、Docker Composeで起動するdockerコンテナには影響がありません。
また、今回はテスト実行時にDynamoDB Localのコンテナを1回だけ起動して、全テストで使いまわしています。
テストクラスごと、あるいはテストメソッドごとにコンテナの起動、破棄をすることも可能ですが、テストが増えてくるとその分時間が掛かってしまうため今回は使い回すようにしています。

テーブル作成・データクリアのヘルパー

テスト用にテーブル作成とデータクリアを行うヘルパークラスを用意します。

public class DynamoDbTableHelper {

    // テーブルが存在しなければ作成する
    public static void createTableIfNotExist(DynamoDbClient client) {
        var existingTables = client.listTables().tableNames();
        if (existingTables.contains(Book.TABLE_NAME)) {
            return;
        }
        client.createTable(/* テーブル定義 */);
    }

    // 全データを削除する(テーブルは残す)
    public static void truncateTable(DynamoDbClient client) {
        var scanResult = client.scan(ScanRequest.builder()
                .tableName(Book.TABLE_NAME).build());
        for (var item : scanResult.items()) {
            client.deleteItem(DeleteItemRequest.builder()
                    .tableName(Book.TABLE_NAME)
                    .key(Map.of(Book.BOOK_ID, item.get(Book.BOOK_ID)))
                    .build());
        }
    }
}

データの削除ですが、DynamoDbTestConfigの説明に記載した通り、全テストでコンテナを使いまわしているため前のテストのデータが残ってしまいます。
RDBのテストでは@Transactionalアノテーションを付けることでテスト後に自動ロールバックできますが、DynamoDBにはそのような仕組みがありません。そのため、各テストの実行前にscan → deleteItemで全データを削除し、テスト間のデータ独立性を確保しています。

テストクラス

@SpringBootTest(classes = {DynamoDbTestConfig.class, BookMapper.class})
class BookMapperTest {

    @Autowired
    BookMapper bookMapper;

    @Autowired
    DynamoDbClient dynamoDbClient;

    @BeforeEach
    void setUp() {
        DynamoDbTableHelper.createTableIfNotExist(dynamoDbClient);
        DynamoDbTableHelper.truncateTable(dynamoDbClient);
    }

    @Test
    void create_書籍を登録して取得できる() {
        var book = new Book("book-001", "Spring入門", "山田太郎", "技術書", 3000);

        bookMapper.create(book);

        var result = bookMapper.findById("book-001");
        assertNotNull(result);
        assertEquals("Spring入門", result.getTitle());
    }
}

@BeforeEachアノテーションを使用してsetUp()でDynamoDbTableHelperのtruncateTable()を実行することで各テストメソッドの実行前に毎回データをクリーンしています。

テスト実行

gradlew test

Docker Composeの事前起動は不要ですが、Docker Desktopは起動している必要があります。
その状態でテストを実行すると、DynamoDB Localのコンテナが起動して、テストが完了すると自動でコンテナが削除されます。

おわりに

今回はDynamoDB localを使ったローカル開発環境とテスト環境の構築方法を試しました。
Docker Composeを使えばローカルでDynamoDBのデータを確認しながら開発できますし、Testcontainersを使えばgradlew testだけでDynamoDBを使ったテストを実行できます。どちらもDockerさえあれば導入できるので、DynamoDBを使った開発をされている方はぜひ試してみてください。

--------------------------
開発支援・技術研修のご要望・ご相談はこちらから
--------------------------
【この技術ブログを読んだエンジニアの皆様へ】
カサレアルブログをお読みいただき、ありがとうございます!

私たちは、常に新しい技術に挑戦し、ユーザーのニーズに応えるサービスを提供しています。
もし、当社の技術への情熱や、会社・チーム・社員の雰囲気に共感いただけたなら、
ぜひ私たちと一緒に働きませんか?
現在、株式会社カサレアルでは事業拡大に伴い、新たな仲間となるエンジニアを積極的に募集しています。

少しでも興味をお持ちいただけましたら、まずは弊社のことを知っていただけると嬉しいです。
▼採用サイト
https://www.casareal.co.jp/recruit/career
▼社員インタビュー
https://hrmos.co/pages/casareal/jobs/0000016
▼エンジニアの仲間になる! エントリーはこちらから
https://hrmos.co/pages/casareal/jobs

皆様のエントリーを心よりお待ちしています!

Webシステムの開発見積
NotebookLMに確認テストの作問を手伝ってもらった話

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

コメント ※

名前 ※

メール ※

サイト