🧪

Testcontainersを用いたNext.jsとDBの結合テスト

2024/03/26に公開

Testcontainers というものを知りました。
Testcontainersは、コンテナを利用してテスト環境を構築し、簡単に統合テストを行うことができるツールです。
この便利なツールを用いて、Next.jsとデータベース(DB)を組み合わせた結合テストを実施しました。
使い捨て可能なエンドツーエンド(E2E)テストの実装ができそうで、とても良さそうに思いました。
試した内容について、簡単に紹介します。

以下のGitHubリポジトリで、今回紹介するコードを確認できます。

実装手順

私が試したことを以下に簡単にまとめました。

  • テストのセットアップ
    • テストフレームワークとしてVitestを設定
    • MySQLコンテナをデータベースとして作成し、必要なマイグレーションとシーディングを実施
  • アプリケーションのセットアップ
    • Dockerfileを使用してNext.jsアプリケーションを起動
    • アプリケーションがMySQLデータベースに適切に接続できるように設定
  • テストの実施
    • ブラウザ自動化ツールであるPlaywrightを使用し、Next.jsアプリケーションへのアクセス及びテストの実行

この設計は特別な環境を用意する必要がなく、GitHub Actions でも動くことを確認しました。

実装例を見よう

ここでは、前述した実験手順に基づいて実際に記述したコードの紹介を行います。
具体的な実装に関しては、GitHub上に公開しています

テストのセットアップ

まずはテストのセットアップを行います。
これには、テストフレームワークのVitestのセットアップから始めます。Vitestの詳細なセットアップ方法については、Vitestの公式ガイドを参照してください。

以下のサンプルコードは、テストの基本的な構造を示しています。

// sample.test.ts
import { afterAll, beforeAll, describe, vi } from "vitest";

describe("App and Database Containers Integration Test", () => {
  vi.setConfig({ testTimeout: 600_000, hookTimeout: 600_000 });
  beforeAll(async () => {});
  afterAll(async () => {});
});

Testcontainersを使用する際、テストの実行時間が長くなる可能性があるため、testTimeouthookTimeoutの値を拡大しています。
テストの実行には、以下のコマンドを使用します。

このコードに対して、以下のコマンドを実行します。

DEBUG=testcontainers* vitest

このコマンドでは、DEBUG=testcontainers*を指定しており、これはTestcontainersによる詳細なログ出力を有効にするためです。
ログの設定に関する詳細は、Testcontainersの設定ページを参照してください。

次に、テストに必要なデータベースの準備を行います。
以下のサンプルコードでは、MySQLコンテナの起動と、マイグレーションおよびダミーデータのシーディングを実行しています。

// sample.test.ts
import { afterAll, beforeAll, describe } from "vitest";
import { MySqlContainer, StartedMySqlContainer } from "@testcontainers/mysql";
import { createPool } from "mysql2";
import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import { faker } from "@faker-js/faker";
import * as schema from "../server/db/schema";

describe("App and Database Containers Integration Test", () => {
  // ...
  let mysqlContainer: StartedMySqlContainer;
  beforeAll(async () => {
    mysqlContainer = await new MySqlContainer()
      .withDatabase("t3-app-nextjs-testcontainers")
      .start();
    const databaseUrl = `mysql://${mysqlContainer.getUsername()}:${mysqlContainer.getUserPassword()}@${mysqlContainer.getHost()}:${mysqlContainer.getFirstMappedPort()}/${mysqlContainer.getDatabase()}`;
    const db = drizzle(
      createPool({
        uri: databaseUrl,
      }),
      {
        schema,
        mode: "default",
      },
    );
    await migrate(db, { migrationsFolder: "src/server/db/out" });
    const data: (typeof schema.posts.$inferInsert)[] = [];
    for (let i = 0; i < 20; i++) {
      data.push({
        name: faker.internet.userName(),
      });
    }
    await db.insert(schema.posts).values(data);
  });
  afterAll(async () => {
    await mysqlContainer.stop();
  });
});

テスト完了後は、afterAllフックを使用してMySQLコンテナを停止します。これにより、テスト環境をクリーンな状態に保つことができます。

Testcontainersでは、サポートされているコンテナ(例えばMySQL)と汎用的なコンテナ(Generic)の両方を使用することができ、
どちらを使っても良いと思います。
サポートされているコンテナの詳細については、Testcontainersのモジュール一覧ページを参照してください。

アプリケーションのセットアップ

アプリケーションのセットアップは、Next.jsのフレームワークを用いて効率的に進めます。
まず、npm create t3-app@latest コマンドを使用し、Next.jsでデータベースを含めたアプリケーションの基盤を簡単に作成します。
このプロジェクトでは、ORMとしてdrizzleを使用しています。
Next.jsのDocker化に関しては、T3スタックの公式ガイドに従い、Dockerfileを作成します。

以下は、テストコードsample.test.tsの続きで、アプリケーションとデータベースのコンテナを統合する部分です。

// sample.test.ts
import { afterAll, beforeAll, describe } from "vitest";
import { MySqlContainer, StartedMySqlContainer } from "@testcontainers/mysql";
import { GenericContainer, StartedTestContainer } from "testcontainers";

describe("App and Database Containers Integration Test", () => {
  // ...
  let mysqlContainer: StartedMySqlContainer;
  let appContainer: StartedTestContainer;
  beforeAll(async () => {
    mysqlContainer = await new MySqlContainer()
      .withDatabase("t3-app-nextjs-testcontainers")
      .start();
    // ...
    const innerDatabaseUrl = `mysql://${mysqlContainer.getUsername()}:${mysqlContainer.getUserPassword()}@${mysqlContainer.getIpAddress(mysqlContainer.getNetworkNames()[0] ?? "")}:3306/${mysqlContainer.getDatabase()}`;
    const appImage = await GenericContainer.fromDockerfile("./")
      .withBuildArgs({ NEXT_PUBLIC_CLIENTVAR: "clientvar" })
      .withCache(true)
      .build("app", { deleteOnExit: false });
    appContainer = await appImage
      .withEnvironment({ DATABASE_URL: innerDatabaseUrl, PORT: "3000" })
      .withExposedPorts(3000)
      .start();
  });
  afterAll(async () => {
    await appContainer.stop();
    await mysqlContainer.stop();
  });
});

このコードでは、GenericContainer.fromDockerfile を使用して、
現在のディレクトリにあるDockerfileからアプリケーションのDockerイメージをビルドしています。
ビルド時の引数はwithBuildArgsを通じて渡され、コンテナの環境変数はwithEnvironmentで設定されます。
これは、Docker CLIを使用するときと似ていますね。Docker Compose も使えます。

また、データベースへの接続情報としてinnerDatabaseUrlには、ホスト側ではなくコンテナ側のポート(この場合は3306)を使用する必要があります。
これは、コンテナ間通信を意識した設定です。

これにより、データベースとアプリケーションのコンテナが準備完了となり、統合テストの実行に移ることができます。

テストの実施

テストの最終段階では、Playwrightを用いてブラウザを介したアプリケーションのテストを行います。
Playwrightのセットアップと基本的な使い方については、Playwrightの公式ドキュメントを参照してください。

以下のコードスニペットは、sample.test.ts ファイルにPlaywrightを組み込んだテストの例を示しています。

// sample.test.ts
import { afterAll, beforeAll, describe, it } from "vitest";
import { MySqlContainer, StartedMySqlContainer } from "@testcontainers/mysql";
import { GenericContainer, StartedTestContainer } from "testcontainers";
import { type Browser, type Page, chromium } from "@playwright/test";

describe("App and Database Containers Integration Test", () => {
  // ...
  let mysqlContainer: StartedMySqlContainer;
  let appContainer: StartedTestContainer;
  let browser: Browser;
  let page: Page;
  beforeAll(async () => {
    mysqlContainer = await new MySqlContainer()
      .withDatabase("t3-app-nextjs-testcontainers")
      .start();
    // ...
    appContainer = await appImage
      .withEnvironment({ DATABASE_URL: innerDatabaseUrl, PORT: "3000" })
      .withExposedPorts(3000)
      .start();
    browser = await chromium.launch();
    page = await browser.newPage();
  });
  afterAll(async () => {
    await appContainer.stop();
    await mysqlContainer.stop();
    await browser.close();
  });
  it("should interact with the app through the browser", async () => {
    const url = `http://${appContainer.getHost()}:${appContainer.getFirstMappedPort()}`;
    await page.goto(url);
    await page.screenshot({ path: "screenshots/screenshot-1.png" });
    await page.getByPlaceholder("Title").fill("Hello World");
    await page.screenshot({ path: "screenshots/screenshot-2.png" });
    await page.getByRole("button", { name: "Submit" }).click();
    await page.screenshot({ path: "screenshots/screenshot-3.png" });
    await page.locator("button").isEnabled();
    await page.waitForSelector("text=Your most recent post: Hello World");
    await page.screenshot({ path: "screenshots/screenshot-4.png" });
  });
});

このコードでは、まずChromiumブラウザを起動し、新しいページを開きます。
その後、テスト対象のアプリケーションにアクセスし、フォームへの入力やボタンのクリックなど、ユーザー操作をシミュレートしています。
スクリーンショットを見るとわかるのですが、DBへのPOSTやGETも機能しているので、実際に稼働しているアプリケーションに近いものになっています。

このテストは、GitHub Actionsを使用して自動化することも可能です。
以下のコードは、GitHub Actionsの設定例です。

name: Node.js CI
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js 18.x
      uses: actions/setup-node@v3
      with:
        node-version: 18.x
        cache: 'npm'
    - run: npm ci
    - run: npx playwright install --with-deps
    - run: npm test
    - uses: actions/upload-artifact@v4
      if: always()
      with:
        name: screenshots
        path: ./screenshots/
        retention-days: 30

このようにして、Next.jsとデータベースが統合された使い捨ての結合テスト環境をGitHub Actionsを通じて実現することができます。

テストのメリットとデメリット

Testcontainersを使用したテストアプローチには、いくつかのメリットとデメリットがあると思います。

メリット

  1. 環境の即時構築

Dockerイメージを使用してテスト環境をゼロから構築し、データのシーディングまで一気に行うことが可能です。
これにより、実際の環境を模倣したテストが手軽に実施できます。

  1. 軽量かつ迅速

Dockerコンテナを起動するだけで済むため、環境構築にかかる時間と労力を削減できます。
これにより、開発サイクルを速めることが可能になります。

  1. 使い捨てが可能

テスト後に環境を簡単に破棄できるため、毎回クリーンな状態からテストを開始することができます。
これは、テストの再現性と信頼性を保証します。

  1. GitHub Actionsでの実行

GitHub Actionsを活用することで、テストプロセスを自動化し、CI/CDパイプラインに組み込むことが可能です。
これにより、小規模プロジェクトでも効率的に開発を進めることができます。

デメリット

  1. セットアップ時間の増加

コンテナの規模が大きくなると、セットアップに要する時間が長くなることがあります。これは、テストプロセスの迅速化を求める場合には考慮すべき点です。

  1. マシンリソースの消費

複数のコンテナを同時に稼働させると、マシンリソースを大量に消費する可能性があります。これは、特にリソースに限りのある環境でのテスト実施において注意が必要です。

テストの使い捨て可能な性質とこれらのトレードオフがあるのかと思います。
アプリケーションの規模が大きくなるにつれ、コンテナセットアップの複雑さが増す可能性があります。
しかし、Dockerイメージの事前準備やDocker Composeの活用により、これらの課題をある程度軽減することが可能です。

結論

Testcontainersを活用したことで、Node.js環境でも、Vitestなどのテストライブラリと併用して使えることが明らかになりました。
これまで、エンドツーエンド(E2E)テストを行う際には、テスト環境の準備やメンテナンスに伴うコストと労力が課題となっていました。
しかし、Testcontainersを用いることで、使い捨て可能な結合テスト環境を簡単に構築できるようになり、新たな選択肢の一つになるかと思います。

まだ検証段階で、実際に実務で考えると課題もあると思います。
しかし、個人的にはこの設計に魅力を感じています!

Discussion