👏

DynamoDB LocalからLocalStackへの移行とJestによる自動テストの並列実行 with dynamodb-toolbox

2023/06/25に公開

はじめに

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テストを並列で行う方法

Discussion