CLOVER🍀

That was when it all began.

TestcontainersをNode.js(TypeScript)で試す

これは、なにをしたくて書いたもの?

JUnitを使ったテストでコンテナの利用をサポートする、Testcontainersというライブラリがあります。

Testcontainers

こう書くとJava向けのライブラリのようなのですが、他の言語でも扱えるようなので試してみることにしました。

今回は、Node.jsで扱います。

Testcontainers

最初に書きましたが、TestcontainersはJUnitでのテストをサポートするJavaのライブラリです。

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

Testcontainers

このWebサイトを見ていても、特に他の言語の話は出てこないのですが。

GitHubのOrganizationを見ていると、他の言語向けのリポジトリが存在することがわかります。

testcontainers · GitHub

今回は、この中でNode.js向けのTestcontainersを使ってみたいと思います。

Node.js向けのTestcontainers

Node.js向けのTestcontainersのリポジトリは、こちら。

GitHub - testcontainers/testcontainers-node: TestContainers is a NodeJS library that supports tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

TypeScriptで書かれているみたいですね。現時点でのバージョンは、8.12.0です。

Testcontainersの設定は、環境変数で行うようです。

Testcontainers / Configuration

基本的にはGenericContainerを使ってコンテナの操作を行うようですが、Testcontainers側でいくつか用意されているモジュールを使うことも
できるようです。

testcontainers-node/src/modules at v8.12.0 · testcontainers/testcontainers-node · GitHub

用意されているのは、以下ですね。

Docker Composeもサポートしているようです。

Testcontainers / Docker Compose

ドキュメントを見ていくのはこれくらいにして、ちょっと試してみましょう。

今回は、RedisとTestcontainersが用意しているMySQLを使ってみたいと思います。

環境

今回の環境は、こちら。

$ node --version
v16.16.0


$ npm --version
8.11.0


$ docker version
Client: Docker Engine - Community
 Version:           20.10.17
 API version:       1.41
 Go version:        go1.17.11
 Git commit:        100c701
 Built:             Mon Jun  6 23:02:57 2022
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.17
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.17.11
  Git commit:       a89b842
  Built:            Mon Jun  6 23:01:03 2022
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.7
  GitCommit:        0197261a30bf81f1ee8e6a4dd2dea0ef95d67ccb
 runc:
  Version:          1.1.3
  GitCommit:        v1.1.3-0-g6724737
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Node.js+TypeScriptプロジェクトの作成と、Testcontainersのインストール

まずは、Node.jsのプロジェクトを作成して、TypeScriptとテスト用にJestをインストールします。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D @types/node@v16
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir src test

各種設定ファイル。

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"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

package.jsonscriptsは、こんな感じにしておきました。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

Testcontainersのインストール。

$ npm i -D testcontainers

これで、devDependenciesはこのようになりました。

  "devDependencies": {
    "@types/jest": "^28.1.6",
    "@types/node": "^16.11.47",
    "esbuild": "^0.15.1",
    "esbuild-jest": "^0.5.0",
    "jest": "^28.1.3",
    "prettier": "2.7.1",
    "testcontainers": "^8.12.0",
    "typescript": "^4.7.4"
  }

RedisとMySQLにアクセスするためのモジュールをインストール。

$ npm i ioredis mysql2
  "dependencies": {
    "ioredis": "^5.2.2",
    "mysql2": "^2.3.3"
  }

では、テストコードを書いていきましょう。

Testcontainersを使ってみる

最初は、GenericContainerを使っていきましょう。Examplesを見つつ、進めていきます。

Testcontainers / Examples

使用するコンテナイメージはRedisのものにして、クライアントはioredisを使用することにします。

redis

GitHub - luin/ioredis: 🚀 A robust, performance-focused, and full-featured Redis client for Node.js.

作成したテストコードは、こちら。

test/redis-container.test.ts

import Redis from 'ioredis';
import { GenericContainer, StartedTestContainer } from 'testcontainers';

jest.setTimeout(15000);

describe('GenericContainer for Redis, simply', () => {
  test('start - stop, Redis container', async () => {
    const redisContainer = await new GenericContainer('redis:7.0.4')
      .withExposedPorts(6379)
      .start();

    await redisContainer.stop();
  });
});

describe('GenericContainer for Redis', () => {
  const exposePort = 6379;
  let redisContainer: StartedTestContainer;
  let redis: Redis;

  beforeAll(async () => {
    redisContainer = await new GenericContainer('redis:7.0.4')
      .withExposedPorts(exposePort)
      .start();

    redis = new Redis(
      redisContainer.getMappedPort(exposePort),
      redisContainer.getHost()
    );
  });

  afterAll(async () => {
    await redis.quit();
    await redisContainer.stop();
  });

  test('set - get', async () => {
    await redis.set('key1', 'value1');

    expect(await redis.get('key1')).toBe('value1');

    await redis.del('key1');

    expect(await redis.get('key1')).toBeNull();
  });
});

簡単に、起動と停止のみ。GenericContainerのコンストラクタで使用したいイメージを指定します。

describe('GenericContainer for Redis, simply', () => {
  test('start - stop, Redis container', async () => {
    const redisContainer = await new GenericContainer('redis:7.0.4')
      .withExposedPorts(6379)
      .start();

    await redisContainer.stop();
  });
});

起動はstart、停止はstopです。withExposedPortsでポートを指定しておくと、その後でgetMappedPortでコンテナと接続するポートが
取得できるようになります。

次は、もう少し幅を広げてbeforeAllでRedisコンテナを起動、afterAllでコンテナを停止するようにしてみます。

describe('GenericContainer for Redis', () => {
  const exposePort = 6379;
  let redisContainer: StartedTestContainer;
  let redis: Redis;

  beforeAll(async () => {
    redisContainer = await new GenericContainer('redis:7.0.4')
      .withExposedPorts(exposePort)
      .start();

    redis = new Redis(
      redisContainer.getMappedPort(exposePort),
      redisContainer.getHost()
    );
  });

  afterAll(async () => {
    await redis.quit();
    await redisContainer.stop();
  });

  test('set - get', async () => {
    await redis.set('key1', 'value1');

    expect(await redis.get('key1')).toBe('value1');

    await redis.del('key1');

    expect(await redis.get('key1')).toBeNull();
  });
});

この時、GenericContainer#startの結果であるStartedTestContainerインスタンスから、コンテナのホストやポートを取得できるので、
こちらをRedisへの接続情報としてioredisに渡しています。

  beforeAll(async () => {
    redisContainer = await new GenericContainer('redis:7.0.4')
      .withExposedPorts(exposePort)
      .start();

    redis = new Redis(
      redisContainer.getMappedPort(exposePort),
      redisContainer.getHost()
    );
  });

あとは、Redisに対してアクセスしているだけですね。

  test('set - get', async () => {
    await redis.set('key1', 'value1');

    expect(await redis.get('key1')).toBe('value1');

    await redis.del('key1');

    expect(await redis.get('key1')).toBeNull();
  });

最後に、afterAllで停止。

  afterAll(async () => {
    await redis.quit();
    await redisContainer.stop();
  });

ところで、Jestで実行するテストのデフォルトのタイムアウトは5秒なわけですが、これを伸ばしています。

jest.setTimeout(15000);

伸ばさない場合、状況によってはこんなことになります。

$ npm test

> testcontainers-example@1.0.0 test
> jest

 FAIL  test/redis-container.test.ts (10.481 s)
  ● GenericContainer for Redis, simply › start - stop, Redis container

    thrown: "Exceeded timeout of 5000 ms for a test.
    Use jest.setTimeout(newTimeout) to increase the timeout value, if this is a long-running test."

      20 |     redisContainer = await new GenericContainer('redis:7.0.4')
      21 |       .withExposedPorts(exposePort)
    > 22 |       .start();
         |   ^
      23 |
      24 |     redis = new Redis(
      25 |       redisContainer.getMappedPort(exposePort),

      at test/redis-container.test.ts:22:3
      at Object.<anonymous> (test/redis-container.test.ts:21:1)


〜省略〜

MySQL用のモジュールを使用する

次は、Testcontainersが用意しているモジュールのうち、

https://github.com/testcontainers/testcontainers-node/tree/v8.12.0/src/modules

MySQL用のものを使ってみたいと思います。

https://github.com/testcontainers/testcontainers-node/tree/v8.12.0/src/modules/mysql

中身を見ると、MySQL用にカスタマイズしたGenericContainerStartedTestContainerが含まれているという感じですね。

https://github.com/testcontainers/testcontainers-node/blob/v8.12.0/src/modules/mysql/mysql-container.ts

使用するコンテナイメージはデフォルトでMySQLのオフィシャルイメージですが、タグを明示的に指定して使うことにします。
クライアントはnode-mysql2を使用することにします。

mysql

GitHub - sidorares/node-mysql2: fast mysqljs/mysql compatible mysql driver for node.js

テストコードは、こんな感じになりました。

test/mysql-container.test.ts

import mysql, { Connection } from 'mysql2/promise';
import { MySqlContainer, StartedMySqlContainer } from 'testcontainers';

jest.setTimeout(240000);

describe('MySqlContainer', () => {
  let mysqlContainer: StartedMySqlContainer;
  let mysqlConnection: Connection;

  beforeAll(async () => {
    mysqlContainer = await new MySqlContainer('mysql:8.0.30').start();

    mysqlConnection = await mysql.createConnection({
      host: mysqlContainer.getHost(),
      port: mysqlContainer.getPort(),
      database: mysqlContainer.getDatabase(),
      user: mysqlContainer.getUsername(),
      password: mysqlContainer.getUserPassword(),
    });
  });

  afterAll(async () => {
    await mysqlConnection.end();
    await mysqlContainer.stop();
  });

  test('query', async () => {
    const [rows] = await mysqlConnection.execute('select 1 as res');
    expect(rows).toEqual([{ res: 1 }]);
  });
});

MySqlContainerを使ってコンテナを起動して、StartedMySqlContainerから接続先の情報を取得できます。

  let mysqlContainer: StartedMySqlContainer;
  let mysqlConnection: Connection;

  beforeAll(async () => {
    mysqlContainer = await new MySqlContainer('mysql:8.0.30').start();

    mysqlConnection = await mysql.createConnection({
      host: mysqlContainer.getHost(),
      port: mysqlContainer.getPort(),
      database: mysqlContainer.getDatabase(),
      user: mysqlContainer.getUsername(),
      password: mysqlContainer.getUserPassword(),
    });

デフォルトのアカウントやデータベースの情報がどうなっているかは、こちらを参照しましょう。

https://github.com/testcontainers/testcontainers-node/blob/v8.12.0/src/modules/mysql/mysql-container.ts#L10-L13

with〜メソッドを使用することで、自分で指定することもできます。

バージョンは8.0.30を指定していますが、デフォルトはmysql:8.0.26ですね。

    mysqlContainer = await new MySqlContainer('mysql:8.0.30').start();

https://github.com/testcontainers/testcontainers-node/blob/v8.12.0/src/modules/mysql/mysql-container.ts#L15

ところで、今回はテストのタイムアウトをかなり長めにしています。

jest.setTimeout(240000);

最初、Redisと同じように15秒程度にしていたら、起動しきれずにテストが失敗していたので確認してみたらMySQLコンテナそのものの
起動に時間がかかっていました。

それを認識してか、Testcontainersの方でも起動時の待ち時間が2分になっているんですよね。

https://github.com/testcontainers/testcontainers-node/blob/v8.12.0/src/modules/mysql/mysql-container.ts#L45

ちょっとハマりましたが、いいでしょう。

まとめ

Node.jsでTestcontainersを試してみました。

Java以外の言語でも使えることはなんとなく把握していましたが、ここで1度試しておいてよかったかなと。

コンテナの起動に時間がかかるケース(使用するコンテナイメージ次第ですが)があることを意識しておくと、Jestのタイムアウトを超過して
ハマるようなことにならず、良いかもですね…。