🐳

Testcontainers for NodeJS を Docker in Dokcer環境でRedisやPrismaを試す

2024/07/07に公開

Testcontainers とは

https://testcontainers.com/

Testcontainersは、統合テストやエンドツーエンドテストのために、データベース、メッセージブローカー、ウェブブラウザなどの軽量で使い捨てのDockerコンテナインスタンスを提供するオープンソースフレームワークです。

Testcontainers for NodeJS

https://node.testcontainers.org/

Testcontainersは色んな言語に対応しています。今回はその中のNodeJSを試してみたいと思います。

Docker in Docker

自身がよくDockerを使って開発することが多いので、Docker内でもTestcontainersが使えるのか、テスト遅くなったりしないかなど色々試してみたいと思います。

環境構築

https://github.com/Slowhand0309/nodejs-devcontainer-boilerplate

NodeJSが使えるDocker環境であればどの様に構築してもいいと思いますが、今回も↑をForkし testcontainers-nodejs-example というリポジトリで進めていきます。

NodeJSのVersionは v20.10.0 になってます。

Docker in Docker の Dev Container Features 追加

Dev Container を使う場合、Featuresとして拡張機能みたいなものを追加できます。

今回は↓のDocker in Docker の機能を追加してくれるFeaturesを追加します。

https://github.com/devcontainers/features/tree/main/src/docker-in-docker

.devcontainer/devcontainer.jsonfeatures に以下を追加します。

  "features": {
    ...
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },

パッケージインストール

早速コンテナ起動し、必要なパッケージを追加していきたいと思います。

まずは こちら のQuickstartを試してみたいので、それに向けて必要なパッケージを追加します。

(※ async-redis の代わりに ioredis を使う様にしてます)

yarn add -D testcontainers typescript jest ts-jest @types/jest ioredis @types/ioredis

各設定ファイルは以下の様に設定してます。

  • tsconfig.json

    {
      "compilerOptions": {
        "target": "esnext",
        "module": "commonjs",
        "lib": ["esnext"],
        "baseUrl": "./src",
        "outDir": "dist",
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitOverride": true,
        "noImplicitReturns": true,
        "noPropertyAccessFromIndexSignature": true,
        "esModuleInterop": true
      },
      "include": ["src"]
    }
    
  • jest.config.js

    /** @type {import('ts-jest').JestConfigWithTsJest} */
    module.exports = {
      preset: 'ts-jest',
      testEnvironment: 'node',
      roots: ['tests'],
      testTimeout: 30000,
      testMatch: [
        '**/?(*.)+(spec|test).ts'
      ],
    };
    
    

    ※ 以下のtimeoutが発生する為 testTimeout を伸ばしてます

    thrown: "Exceeded timeout of 5000 ms for a hook.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See [https://jestjs.io/docs/api#testname-fn-timeout."](https://jestjs.io/docs/api#testname-fn-timeout.%22)
    

テスト実装

シンプルなredisのテスト

tests/redis.spec.ts を以下内容で作成します。

import * as IORedis from "ioredis";
import { GenericContainer, StartedTestContainer } from "testcontainers";

describe("Redis", () => {
  let container: StartedTestContainer;
  let redisClient: IORedis.Redis;

  beforeAll(async () => {
    container = await new GenericContainer("redis")
      .withExposedPorts(6379)
      .start();

    redisClient = new IORedis.Redis({
      host: container.getHost(),
      port: container.getMappedPort(6379),
    });
  });

  afterAll(async () => {
    await redisClient.quit();
    await container.stop();
  });

  it("works", async () => {
    await redisClient.set("key", "val");
    expect(await redisClient.get("key")).toBe("val");
  });
});

いざ実行するとテストがパスしました! これで設定は良さそうです。

$ yarn test
yarn run v1.22.19
$ jest
 PASS  tests/redis.spec.ts
  Redis
    ✓ works (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.338 s, estimated 2 s
Ran all test suites.
Done in 1.58s.

テストにかかった時間は5回やった平均を取ると約1.6sでした。

実際のredisのコンテナたてて計測

次は実際にredisのコンテナを起動してそこに接続した場合のテストがどれくらいかかるか計測してみたいと思います。

  • .devcontainer/compose.yaml に以下を追加
services:
  # ↓追加
  redis:
    image: "redis:7.2.5-alpine"
    ports:
      - "6379:6379"
  • 先ほど作成した tests/redis.spec.ts を以下に修正します
import * as IORedis from "ioredis";

describe("Redis", () => {
  let redisClient: IORedis.Redis;

  beforeAll(async () => {
    redisClient = new IORedis.Redis({
      host: "redis",
      port: 6379,
    });
  });

  afterAll(async () => {
    await redisClient.del("key");
    await redisClient.quit();
  });

  it("works", async () => {
    await redisClient.set("key", "val");
    expect(await redisClient.get("key")).toBe("val");
  });
});

準備ができたのでコンテナ再起動して早速テストを実行してみたいと思います。

$ yarn test
yarn run v1.22.19
$ jest
 PASS  tests/redis.spec.ts
  Redis
    ✓ works (5 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.172 s, estimated 1 s
Ran all test suites.
Done in 0.40s.

テストにかかった時間は5回やった平均を取ると約0.4sでした。

という事でTestcontainersを使った場合と比較すると 約1.2s Testcontainersの起動や後片付けでかかっている事になりそうです。普通にdocker起動する時と比べたら当たり前ですが爆速ですね。

大量のテストで毎回Testcontainersを起動するとかだど時間かかっちゃいそうなので、何かしら工夫が必要なのかもですね。

実際ありそうなケースでのテストを試す

prismaを使ったDBアクセスのテスト

今回はprismaとposgresqlの最低限の構成で簡単なテストを試してみたいと思います。

  • .devcontainer/compose.yaml に以下を追加
volumes:
  db_data:

services:
  db:
    image: postgres:16.2
    ports:
      - 5432:5432
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres

コンテナを再起動しときます。

  • 必要なパッケージインストールと初期化
yarn add -D prisma @testcontainers/postgresql
yarn prisma init --datasource-provider postgresql
  • 生成された .env ファイルの DATABASE_URL を以下に修正します。
DATABASE_URL="postgresql://postgres:postgres@db:5432/example?schema=public"
  • prisma/schema.prisma に User テーブルを追加
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

  • マイグレーション実行しときます
yarn prisma migrate dev --name init

これで @prisma/client パッケージが追加され内部的に prisma generate が実行されます。また prisma/migrations 配下にマイグレーション用のファイルが生成されているかと思います。

次にテストを書いていきたいと思います tests/prisma.spec.ts を以下内容で作成します。

import { PrismaClient } from "@prisma/client";
import {
  PostgreSqlContainer,
  StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { exec } from "child_process";
import { promisify } from "util";

const execAsync = promisify(exec);

describe("Prisma", () => {
  let container: StartedPostgreSqlContainer;
  let prisma: PrismaClient;

  beforeAll(async () => {
    container = await new PostgreSqlContainer().start();
    const connectionConfig = {
      host: container.getHost(), // '172.17.0.1'
      port: container.getMappedPort(5432), // 32779
      database: container.getDatabase(), // 'test'
      user: container.getUsername(), // 'test'
      password: container.getPassword(), // 'test'
    };

    // Testcontainersのポスグレコンテナの情報を元にDATABASE_URLを作成
    const databaseUrl = `postgresql://${connectionConfig.user}:${connectionConfig.password}@${connectionConfig.host}:${connectionConfig.port}/${connectionConfig.database}`;
    // マイグレーション実行
    const result = await execAsync(
      `DATABASE_URL=${databaseUrl} npx prisma migrate dev --skip-generate`
    );

    prisma = new PrismaClient({
      datasources: {
        db: {
          url: databaseUrl,
        },
      },
    });
    await prisma.user.create({
      data: {
        email: "john@example.com",
        name: "John Doe",
      },
    });
  });

  it("works", async () => {
    const users = await prisma.user.findMany();
    expect(users).toHaveLength(1);
    expect(users[0].email).toBe("john@example.com");
    expect(users[0].name).toBe("John Doe");
  });

  afterAll(async () => {
    await container.stop();
  });
});

テスト実行させてPassされていればOKです。

$ yarn test tests/prisma.spec.ts
yarn run v1.22.19
$ jest tests/prisma.spec.ts
 PASS  tests/prisma.spec.ts
  Prisma
    ✓ works (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.662 s, estimated 4 s
Ran all test suites matching /tests\/prisma.spec.ts/i.
Done in 3.90s.

こちら5回テストして平均が約 3.9382s でした。

その他

テスト時にTestcontainersのデバッグログを出力したい場合は以下のように DEBUG=testcontainers* をつけて実行します。

DEBUG=testcontainers* yarn test

アスタリスクの部分はbuild時のログやexec時のログなど、色々切り替えて出力できます。

詳しくは↓を参照。

https://node.testcontainers.org/configuration

トラブルシューティング

パッケージインストール時に Unable to detect compiler type error が出る場合

https://github.com/testcontainers/testcontainers-node/issues/652

↑のissueにもある通り、--omit=optional をつけて yarn install してやる

テスト中にAn error occurred listing credentials が発生する場合

https://github.com/microsoft/vscode-remote-release/issues/4202

↑のissueにあるように、VSCode上のターミナルからでは実行でき、 docker exec からだとエラーが起きるようです。詳しくは読み解けなかったのですが、おそらく REMOTE_CONTAINERS_IPCdocker exec だと設定されていないので認証情報を扱う際に失敗するのかもしれないです。

また、↓の記事のように .gitconfigcredential を同期したり、勝手に追加しないようにしてもいけるようです。

https://rexbytes.com/2022/08/23/visual-studio-docker-container-target-stop-importing-local-git-config/

ただ、実際に試してうまくいきましたが、詳細が分からなかったので元に戻しました。。

参考URL

https://zenn.dev/tsaeki/articles/fdf5d7f2cae2fb

https://blog.yarsalabs.com/isolating-test-environments-with-testcontainers-in-nodejs/

Discussion