これは、なにをしたくて書いたもの?
JUnitを使ったテストでコンテナの利用をサポートする、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.
このWebサイトを見ていても、特に他の言語の話は出てこないのですが。
GitHubのOrganizationを見ていると、他の言語向けのリポジトリが存在することがわかります。
今回は、この中でNode.js向けのTestcontainersを使ってみたいと思います。
Node.js向けのTestcontainers
Node.js向けのTestcontainersのリポジトリは、こちら。
TypeScriptで書かれているみたいですね。現時点でのバージョンは、8.12.0です。
Testcontainersの設定は、環境変数で行うようです。
Testcontainers / Configuration
基本的にはGenericContainer
を使ってコンテナの操作を行うようですが、Testcontainers側でいくつか用意されているモジュールを使うことも
できるようです。
testcontainers-node/src/modules at v8.12.0 · testcontainers/testcontainers-node · GitHub
用意されているのは、以下ですね。
- ArangoDB
- Elasticsearch
- Apache Kafka
- MySQL
- NATS
- Neo4j
- PostgreSQL
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.json
のscripts
は、こんな感じにしておきました。
"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を見つつ、進めていきます。
使用するコンテナイメージはRedisのものにして、クライアントはioredisを使用することにします。
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用にカスタマイズしたGenericContainer
やStartedTestContainer
が含まれているという感じですね。
使用するコンテナイメージはデフォルトでMySQLのオフィシャルイメージですが、タグを明示的に指定して使うことにします。
クライアントはnode-mysql2を使用することにします。
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(), });
デフォルトのアカウントやデータベースの情報がどうなっているかは、こちらを参照しましょう。
with〜
メソッドを使用することで、自分で指定することもできます。
バージョンは8.0.30を指定していますが、デフォルトはmysql:8.0.26
ですね。
mysqlContainer = await new MySqlContainer('mysql:8.0.30').start();
ところで、今回はテストのタイムアウトをかなり長めにしています。
jest.setTimeout(240000);
最初、Redisと同じように15秒程度にしていたら、起動しきれずにテストが失敗していたので確認してみたらMySQLコンテナそのものの
起動に時間がかかっていました。
それを認識してか、Testcontainersの方でも起動時の待ち時間が2分になっているんですよね。
ちょっとハマりましたが、いいでしょう。
まとめ
Node.jsでTestcontainersを試してみました。
Java以外の言語でも使えることはなんとなく把握していましたが、ここで1度試しておいてよかったかなと。
コンテナの起動に時間がかかるケース(使用するコンテナイメージ次第ですが)があることを意識しておくと、Jestのタイムアウトを超過して
ハマるようなことにならず、良いかもですね…。