この記事は個人ブログと同じ内容です
はじめに
DynamoDB Localを使用してJestを介した自動テストの際に、並列実行時に予期しないエラーに直面しました。--runInBand
オプションを使って回避していたのですが、テストの数が増えてきたため、直列で実行するのがしんどくなってきたので、解決策について模索してみました。
DynamoDB Localの制約と課題
Jestを使用してテストを並列実行すると、DynamoDB Localが競合状態になり、予期しないエラーが発生することがありました。どうやら、この記事によるとDynamoDB Localは、内部的にSQLiteを使用しているらしいので、同時書き込みが難しそう。。。
LocalStackへの移行
そこで、LocalStackへの移行を試してみました。LocalStackは、AWSのクローン環境を提供し、ローカルでAWSサービスをエミュレートすることができるオープンソースのツールです。(有料版も存在するみたいなのですが、DynamoDB のエミュレータは無料で利用できます。)
- 並列実行のサポート: LocalStackは、AWSサービスをマルチスレッドでエミュレートするため、並列実行時の競合やエラーのリソルブが可能です。これにより、複数のテストケースを同時に実行し、開発プロセスの効率を向上させることができます。
- リアルな環境の再現: LocalStackは、AWSの各種サービス(S3、DynamoDB、Lambdaなど)をローカル環境でエミュレートするため、開発者は実際のAWS環境と同様のセットアップと設定を行うことができます。これにより、本番環境での挙動や相互作用を正確に再現することができます。
- 柔軟なカスタマイズ性: LocalStackは、docker image も提供されており、必要に応じて構成や拡張が可能です。また、AWS CLIやSDKを使用してLocalStackに対してリクエストを送信することもできます。これにより、既存のテストコードやツールをそのまま利用できるため、移行の際の手間を最小限に抑えることができます。
LocalStackのセットアップ
下記のような docker-compose.yml を用意します。 なお、dynamodb-adminは、ローカルで動作するDynamoDBを、GUIで操作可能なWebベースのツールです。必須ではないがあると便利なので入れています。
# docker-compose.yml version: '3.8' services: localstack: image: localstack/localstack:latest environment: SERVICES: dynamodb ports: - 4566:4566 dynamodb-admin: container_name: dynamodb-admin image: aaronshaf/dynamodb-admin:latest environment: - DYNAMO_ENDPOINT=localstack:4566 ports: - 8001:8001 depends_on: - localstack
作成したら docker コマンドで起動します。
docker compose up -d
テストコード
テストファイルをこんな感じで用意しました。dynamodb client として dynamodb-toolbox を利用しています。 また、今回のコードはこちらにまとめております。 https://github.com/mukaihajime/dynamodbtoolbox
// get.ts テスト対象ファイル import { TableAEntity } from "../../tables/TableA/entities/TableAEntity" export const get = async (pk: string, sk: string) => { const result = await TableAEntity.get({ pk, sk }) return result }
テストファイルは4ファイル作成しました。 - get1.test.ts - get2.test.ts - get3.test.ts - get4.test.ts
// テストファイル import { TableAEntity } from '../../tables/TableA/entities/TableAEntity' import { get } from './get' describe('get', () => { it('should get an item', async () => { await TableAEntity.put({ age: 1, pk: 'pk1', sk: 'sk1', }) const res = await get('pk1', 'sk1') expect(res.Item?.age).toBe(1) expect(res.Item?.pk).toBe('pk1') expect(res.Item?.sk).toBe('sk1') }) })
設定ファイルはこんな感じです。
// jest.config.js module.exports = { setupFilesAfterEnv: ['./jest.setup.js'], testEnvironment: 'node', testMatch: ['**/?(*.)+(spec|test).ts?(x)'], transform: { '^.+\\.ts?$': ['@swc/jest'], }, }
// jest.setup.js const { CreateTableCommand, DeleteTableCommand, DynamoDBClient, ListTablesCommand } = require('@aws-sdk/client-dynamodb') const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb') const { TableA } = require('./tables/tableA/TableA') const createInstance = () => { const client = new DynamoDBClient({ credentials: { accessKeyId: 'DUMMYIDEXAMPLE', secretAccessKey: 'DUMMYIDEXAMPLE', }, endpoint: 'http://localhost:4566', region: 'ap-northeast-1' }) return { client, } } const createCommand = (tableName) => { return new CreateTableCommand({ AttributeDefinitions: [ { AttributeName: 'pk', AttributeType: 'S', }, { AttributeName: 'sk', AttributeType: 'S', }, { AttributeName: 'gsi1pk', AttributeType: 'S', }, { AttributeName: 'gsi1sk', AttributeType: 'S', }, ], BillingMode: 'PAY_PER_REQUEST', GlobalSecondaryIndexes: [ { IndexName: 'gsi1', KeySchema: [ { AttributeName: 'gsi1pk', KeyType: 'HASH', }, { AttributeName: 'gsi1sk', KeyType: 'RANGE', }, ], Projection: { ProjectionType: 'ALL', }, }, ], KeySchema: [ { AttributeName: 'pk', KeyType: 'HASH', }, { AttributeName: 'sk', KeyType: 'RANGE', }, ], TableName: tableName, }) } const deleteCommand = (tableName) => { return new DeleteTableCommand({ TableName: tableName, }) } const listTablesCommand = () => { return new ListTablesCommand({}) } beforeAll(async () => { const marshallOptions = { convertEmptyValues: false, } const translateConfig = { marshallOptions } const { client } = createInstance() TableA.name = TableA.name const { TableNames } = await client.send(listTablesCommand()) if (TableNames.includes(TableA.name)) { const deleteCreditedTableCommand = deleteCommand(TableA.name) await client.send(deleteCreditedTableCommand) } const createCreditedTableCommand = createCommand(TableA.name) await client.send(createCreditedTableCommand) TableA.DocumentClient = DynamoDBDocumentClient.from(client, translateConfig) })
jest コマンドを実行すると、失敗します。
Test Suites: 3 failed, 1 passed, 4 total Tests: 3 failed, 1 passed, 4 total Snapshots: 0 total Time: 3.442 s
Jestの並列実行とテーブル作成の課題
Jestはデフォルトで並列実行されるため、複数のテストケースが同時に実行されます。しかし、テーブル作成時に競合やデータの衝突が発生していました。理由は下記です。
テーブル作成時の競合: 複数のテストケースが同時に実行される場合、それぞれのテストケースが独自のテーブルを作成しようとする可能性があります。この場合、同じテーブル名を使用しようとして競合が発生し、テーブル作成に失敗する可能性があります。競合が発生すると、テストケースの実行が中断されたり、テーブルの状態が不安定になったりすることがあります。
データの衝突: テストケースごとに異なるデータを使用する場合、並列実行時にデータの衝突が発生する可能性があります。例えば、テストケースAとテストケースBが同時に実行され、それぞれが同じテーブルにデータを挿入しようとする場合、データの一貫性が損なわれる可能性があります。また、テーブルのクリーンアップやリセットが不十分な場合、前回のテストケースの残留データが次のテストケースに影響を与えることもあります。
これらの課題を解決するために、JEST_WORKER_IDごとに独自のテーブルを作成するアプローチを採用しました。このアプローチにより、各テストワーカーが独立した環境でテーブルを操作できるため、競合やデータの衝突を回避することができます。また、テーブル作成前に必要な初期データの挿入やテーブルの削除も、各テストワーカーごとに適切に管理されます。
参考: CI 環境でのユニットテストの実行時間を2倍速くした話 (Jest + Mongo DB + Circle CI)
JEST_WORKER_IDごとのテーブル作成アプローチ
JEST_WORKER_ID で worker の番号が取得できるため、これを利用して worker 分のみ database を作るように変更します。
// jest.setup.js const { CreateTableCommand, DeleteTableCommand, DynamoDBClient, ListTablesCommand } = require('@aws-sdk/client-dynamodb') const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb') const { TableA } = require('./tables/tableA/TableA') const createInstance = () => { const client = new DynamoDBClient({ credentials: { accessKeyId: 'DUMMYIDEXAMPLE', secretAccessKey: 'DUMMYIDEXAMPLE', }, endpoint: 'http://localhost:4566', region: 'ap-northeast-1' }) return { client, } } const createCommand = (tableName) => { return new CreateTableCommand({ AttributeDefinitions: [ { AttributeName: 'pk', AttributeType: 'S', }, { AttributeName: 'sk', AttributeType: 'S', }, { AttributeName: 'gsi1pk', AttributeType: 'S', }, { AttributeName: 'gsi1sk', AttributeType: 'S', }, ], BillingMode: 'PAY_PER_REQUEST', GlobalSecondaryIndexes: [ { IndexName: 'gsi1', KeySchema: [ { AttributeName: 'gsi1pk', KeyType: 'HASH', }, { AttributeName: 'gsi1sk', KeyType: 'RANGE', }, ], Projection: { ProjectionType: 'ALL', }, }, ], KeySchema: [ { AttributeName: 'pk', KeyType: 'HASH', }, { AttributeName: 'sk', KeyType: 'RANGE', }, ], TableName: tableName, }) } const deleteCommand = (tableName) => { return new DeleteTableCommand({ TableName: tableName, }) } const listTablesCommand = () => { return new ListTablesCommand({}) } beforeAll(async () => { const marshallOptions = { convertEmptyValues: false, } const translateConfig = { marshallOptions } const { client } = createInstance() TableA.name = TableA.name + process.env.JEST_WORKER_ID // 追加 const { TableNames } = await client.send(listTablesCommand()) if (TableNames.includes(TableA.name)) { const deleteCreditedTableCommand = deleteCommand(TableA.name) await client.send(deleteCreditedTableCommand) } const createCreditedTableCommand = createCommand(TableA.name) await client.send(createCreditedTableCommand) TableA.DocumentClient = DynamoDBDocumentClient.from(client, translateConfig) })
jest を実行してみた結果。
pnpm jest PASS src/sample/get3.test.ts PASS src/sample/get1.test.ts PASS src/sample/get4.test.ts PASS src/sample/get2.test.ts Test Suites: 4 passed, 4 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 3.059 s
参考: [Node.js][Jest]LocalStackを使ったDynamoDBテストを並列で行う方法
現在 back check 開発チームは一緒にはたらく仲間を募集中です!!