Testcontainers for NodeJS を Docker in Dokcer環境でRedisやPrismaを試す
Testcontainers とは
Testcontainersは、統合テストやエンドツーエンドテストのために、データベース、メッセージブローカー、ウェブブラウザなどの軽量で使い捨てのDockerコンテナインスタンスを提供するオープンソースフレームワークです。
Testcontainers for NodeJS
Testcontainersは色んな言語に対応しています。今回はその中のNodeJSを試してみたいと思います。
Docker in Docker
自身がよくDockerを使って開発することが多いので、Docker内でもTestcontainersが使えるのか、テスト遅くなったりしないかなど色々試してみたいと思います。
環境構築
NodeJSが使えるDocker環境であればどの様に構築してもいいと思いますが、今回も↑をForkし testcontainers-nodejs-example
というリポジトリで進めていきます。
NodeJSのVersionは v20.10.0
になってます。
Docker in Docker の Dev Container Features 追加
Dev Container を使う場合、Featuresとして拡張機能みたいなものを追加できます。
今回は↓のDocker in Docker の機能を追加してくれるFeaturesを追加します。
.devcontainer/devcontainer.json
の features
に以下を追加します。
"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時のログなど、色々切り替えて出力できます。
詳しくは↓を参照。
トラブルシューティング
Unable to detect compiler type error
が出る場合
パッケージインストール時に
↑のissueにもある通り、--omit=optional
をつけて yarn install
してやる
An error occurred listing credentials
が発生する場合
テスト中に
↑のissueにあるように、VSCode上のターミナルからでは実行でき、 docker exec
からだとエラーが起きるようです。詳しくは読み解けなかったのですが、おそらく REMOTE_CONTAINERS_IPC
が docker exec
だと設定されていないので認証情報を扱う際に失敗するのかもしれないです。
また、↓の記事のように .gitconfig
の credential
を同期したり、勝手に追加しないようにしてもいけるようです。
ただ、実際に試してうまくいきましたが、詳細が分からなかったので元に戻しました。。
参考URL
Discussion