DynamoDB LocalからLocalStackへの移行とJestによる自動テストの並列実行 with dynamodb-toolbox
はじめに
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 を利用しています。
また、今回のコードはこちらにまとめております。
// 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
Discussion