[Node.js][Jest]LocalStackを使ったDynamoDBテストを並列で行う方法

2021.09.22

吉川@広島です。

テストでのデータベース単位の捉えかた - 日々常々

こちらの記事がはてなブックマークに上がっており、興味深く拝見していました。

テストに閉じたデータベース
ここでのテストはテストメソッドのイメージです。テストインスタンスがクラス単位ならテストクラス単位でもいいんですが、とにかくテストの実行単位ごとに完全に独立したデータベースを使用します。 図はシンプルですが、テストケース数が100ならデータベース数も100になるイメージです。
すべての情報がテストに閉じている、理想の形です。実現できるならこれでいきたい。
荒唐無稽なことを言っているように感じるかもしれませんが、たとえばH2 Database Engineをインメモリでテストごとに名前を変えれば実現できます。

こちらの記述を見て、普段行っているLocalStack上のDynamoDBに対するJest自動テストにおいても活かせそうなことに気づいたのでやってみました。

環境

  • node 14.16.1
  • typescript 4.4.3
  • aws-sdk 3.33.0
  • uuid 8.3.2
  • jest 27.2.1
  • esbuild 0.12.28
  • esbuild-jest 0.5.0
  • docker 20.10.7
  • localstack 0.12.17

感じていた課題

そもそもLocalStackやDynamoDBに限った話ではないですが、

  • 複数のテスト(並列実行)
  • 1つの状態

だと状態管理が競合して失敗します。

Jestはデフォルトでテストで並列実行するため、複数のテストから同時にDBの状態を変更してしまって失敗することがあります。

そのため、テストを並列実行したいならその数だけDBを用意してあげる必要があるわけですが、いい感じの具体的手順が思いつかなかったので「そもそも並列実行をやめる」という方向で手を打っていました。それがJestの --runInBand オプションです。

Jest CLI オプション · Jest

--runInBand
別名: -i。 子プロセスのworker poolを作成せずに現在のプロセスで全てのテストを1つずつ実行します。 デバッグ時に便利です。

ただ、当然ながらテストの実行時間が長くなるというデメリットがあるため、並列でできるならその方が良いよなーと感じていました。

LocakStackを起動

下準備としてLocalStackを起動しておきます。

下記のようなdocker-compose.ymlを用意します。

# docker-compose.yml

version: '3.8'
services:
  localstack:
    image: localstack/localstack:0.12.17
    environment:
      SERVICES: dynamodb
    ports:
      - 4566:4566

上記を作成したらdockerコマンドで起動します。

docker compose up -d

並列実行できないテストコード

まずは並列実行できない例から紹介します。

サンプルとなるテストファイルをいくつか自動生成したいと思います。

  • 「DynamoDBにデータを追加して取得する」テストケース
  • 全く同じ内容のテストを繰り返し実行する

上記を満たすテストファイルを生成するスクリプトを用意しました。

import fs from 'fs'

const times = 1 // テストの数を指定
const fileName = `./dynamodb-repeat-${times}.test.ts`

const baseCode = `
  import {
    CreateTableCommand,
    DeleteTableCommand,
    DynamoDBClient,
    ListTablesCommand,
    ScanCommand,
  } from '@aws-sdk/client-dynamodb'
  import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'
  import { v4 as uuidV4 } from 'uuid'
`

fs.writeFileSync(fileName, baseCode)

const appendCode = `
  describe('DynamoDBテスト', () => {
    const region = 'ap-northeast-1'
    const tableName = 'users'
    const ddbClient = new DynamoDBClient({
      region,
      endpoint: 'http://localhost:4566', // LocalStackに接続
      credentials: {
        accessKeyId: 'DUMMY', // LocalStackなので任意の値でOK
        secretAccessKey: 'DUMMY', // LocalStackなので任意の値でOK
      },
    })
    const ddbDocClient = DynamoDBDocumentClient.from(ddbClient)

    beforeEach(async () => {
      const { TableNames = [] } = await ddbClient.send(new ListTablesCommand({}))

      // テーブルの存在確認する
      if (TableNames.includes(tableName)) {
        // 存在する場合は削除(データクリアのため)
        await ddbClient.send(
          new DeleteTableCommand({
            TableName: tableName,
          })
        )
      }

      // テーブルを作成
      await ddbClient.send(
        new CreateTableCommand({
          TableName: tableName,
          AttributeDefinitions: [
            {
              AttributeName: 'id',
              AttributeType: 'S',
            },
          ],
          KeySchema: [
            {
              AttributeName: 'id',
              KeyType: 'HASH',
            },
          ],
          BillingMode: 'PAY_PER_REQUEST',
        })
      )
    })

    it('ユーザを1件作成して取得する', async () => {
      // uuidをハッシュキーとしてデータ作成
      await ddbDocClient.send(
        new PutCommand({
          TableName: tableName,
          Item: {
            id: uuidV4(),
          },
        })
      )

      // Scanでデータを取得
      const { Items: items = [] } = await ddbDocClient.send(
        new ScanCommand({
          TableName: tableName,
        })
      )

      // データを1件取得できること
      expect(items.length).toBe(1)
    })
  })
`

for (let i = 0; i < times; i++) {
  fs.appendFileSync(fileName, appendCode)
}

最初の times 変数でテストの数を決定します。timesを1として実行すると次のような dynamodb-repeat-1.test.ts ファイルが生成されました。

// dynamodb-repeat-1.test.ts

import {
  CreateTableCommand,
  DeleteTableCommand,
  DynamoDBClient,
  ListTablesCommand,
  ScanCommand,
} from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'
import { v4 as uuidV4 } from 'uuid'

describe('DynamoDBテスト', () => {
  const region = 'ap-northeast-1'
  const tableName = 'users'
  const ddbClient = new DynamoDBClient({
    region,
    endpoint: 'http://localhost:4566', // LocalStackに接続
    credentials: {
      accessKeyId: 'DUMMY', // LocalStackなので任意の値でOK
      secretAccessKey: 'DUMMY', // LocalStackなので任意の値でOK
    },
  })
  const ddbDocClient = DynamoDBDocumentClient.from(ddbClient)

  beforeEach(async () => {
    const { TableNames = [] } = await ddbClient.send(new ListTablesCommand({}))

    // テーブルの存在確認する
    if (TableNames.includes(tableName)) {
      // 存在する場合は削除(データクリアのため)
      await ddbClient.send(
        new DeleteTableCommand({
          TableName: tableName,
        })
      )
    }

    // テーブルを作成
    await ddbClient.send(
      new CreateTableCommand({
        TableName: tableName,
        AttributeDefinitions: [
          {
            AttributeName: 'id',
            AttributeType: 'S',
          },
        ],
        KeySchema: [
          {
            AttributeName: 'id',
            KeyType: 'HASH',
          },
        ],
        BillingMode: 'PAY_PER_REQUEST',
      })
    )
  })

  it('ユーザを1件作成して取得する', async () => {
    // uuidをハッシュキーとしてデータ作成
    await ddbDocClient.send(
      new PutCommand({
        TableName: tableName,
        Item: {
          id: uuidV4(),
        },
      })
    )

    // Scanでデータを取得
    const { Items: items = [] } = await ddbDocClient.send(
      new ScanCommand({
        TableName: tableName,
      })
    )

    // データを1件取得できること
    expect(items.length).toBe(1)
  })
})

同じ要領でテスト100件版の dynamodb-repeat-100.test.ts とテスト101件版の dynamodb-repeat-101.test.ts 、そしてテスト102件版の dynamodb-repeat-102.test.tsを作成しました。

  • dynamodb-repeat-1.test.ts
  • dynamodb-repeat-100.test.ts
  • dynamodb-repeat-101.test.ts
  • dynamodb-repeat-102.test.ts

この4ファイルがある状態でJestを実行します。

これを jest コマンドで実行すると、

Test Suites: 3 failed, 1 passed, 4 total
Tests:       220 failed, 84 passed, 304 total
Snapshots:   0 total
Time:        19.68 s

このようにいくつが失敗しますね。

ちなみに、 jest --runInBand で実行すると、

Test Suites: 4 passed, 4 total
Tests:       304 passed, 304 total
Snapshots:   0 total
Time:        26.441 s, estimated 48 s

このように成功します。

並列実行できるテストコード

続いて本題の並列実行できるコードです。

冒頭のブログから着想を得て、「テストごとにテーブル名をUUIDで用意すれば状態を独立させられそうだな」と思ったのでやってみました。

ではファイル生成スクリプト。

import fs from 'fs'

const times = 300 // テストの数を指定
const fileName = `./dynamodb-repeat-${times}.test.ts`

const baseCode = `
  import {
    CreateTableCommand,
    DeleteTableCommand,
    DynamoDBClient,
    ListTablesCommand,
    ScanCommand,
  } from '@aws-sdk/client-dynamodb'
  import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'
  import { v4 as uuidV4 } from 'uuid'
`

fs.writeFileSync(fileName, baseCode)

const appendCode = `
  describe('DynamoDBテスト', () => {
    const region = 'ap-northeast-1'
-   const tableName = 'users' 
+   const tableName = \`users-\${uuidV4()}\`
    const ddbClient = new DynamoDBClient({
      region,
      endpoint: 'http://localhost:4566', // LocalStackに接続
      credentials: {
        accessKeyId: 'DUMMY', // LocalStackなので任意の値でOK
        secretAccessKey: 'DUMMY', // LocalStackなので任意の値でOK
      },
    })
    const ddbDocClient = DynamoDBDocumentClient.from(ddbClient)

    beforeEach(async () => {
      const { TableNames = [] } = await ddbClient.send(new ListTablesCommand({}))

      // テーブルの存在確認する
      if (TableNames.includes(tableName)) {
        // 存在する場合は削除(データクリアのため)
        await ddbClient.send(
          new DeleteTableCommand({
            TableName: tableName,
          })
        )
      }

      // テーブルを作成
      await ddbClient.send(
        new CreateTableCommand({
          TableName: tableName,
          AttributeDefinitions: [
            {
              AttributeName: 'id',
              AttributeType: 'S',
            },
          ],
          KeySchema: [
            {
              AttributeName: 'id',
              KeyType: 'HASH',
            },
          ],
          BillingMode: 'PAY_PER_REQUEST',
        })
      )
    })

    it('ユーザを1件作成して取得する', async () => {
      // uuidをハッシュキーとしてデータ作成
      await ddbDocClient.send(
        new PutCommand({
          TableName: tableName,
          Item: {
            id: uuidV4(),
          },
        })
      )

      // Scanでデータを取得
      const { Items: items = [] } = await ddbDocClient.send(
        new ScanCommand({
          TableName: tableName,
        })
      )

      // データを1件取得できること
      expect(items.length).toBe(1)
    })
  })
`

for (let i = 0; i < times; i++) {
  fs.appendFileSync(fileName, appendCode)
}

変えたのは1行だけで、テーブル名にUUIDを加えるようにしただけです。

こちらもまずはtimesを1にして dynamodb-repeat-1.test.ts を作成しました。

// dynamodb-repeat-1.test.ts

import {
  CreateTableCommand,
  DeleteTableCommand,
  DynamoDBClient,
  ListTablesCommand,
  ScanCommand,
} from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'
import { v4 as uuidV4 } from 'uuid'

describe('DynamoDBテスト', () => {
  const region = 'ap-northeast-1'
- const tableName = 'users'
+ const tableName = `users-${uuidV4()}`
  const ddbClient = new DynamoDBClient({
    region,
    endpoint: 'http://localhost:4566', // LocalStackに接続
    credentials: {
      accessKeyId: 'DUMMY', // LocalStackなので任意の値でOK
      secretAccessKey: 'DUMMY', // LocalStackなので任意の値でOK
    },
  })
  const ddbDocClient = DynamoDBDocumentClient.from(ddbClient)

  beforeEach(async () => {
    const { TableNames = [] } = await ddbClient.send(new ListTablesCommand({}))

    // テーブルの存在確認する
    if (TableNames.includes(tableName)) {
      // 存在する場合は削除(データクリアのため)
      await ddbClient.send(
        new DeleteTableCommand({
          TableName: tableName,
        })
      )
    }

    // テーブルを作成
    await ddbClient.send(
      new CreateTableCommand({
        TableName: tableName,
        AttributeDefinitions: [
          {
            AttributeName: 'id',
            AttributeType: 'S',
          },
        ],
        KeySchema: [
          {
            AttributeName: 'id',
            KeyType: 'HASH',
          },
        ],
        BillingMode: 'PAY_PER_REQUEST',
      })
    )
  })

  it('ユーザを1件作成して取得する', async () => {
    // uuidをハッシュキーとしてデータ作成
    await ddbDocClient.send(
      new PutCommand({
        TableName: tableName,
        Item: {
          id: uuidV4(),
        },
      })
    )

    // Scanでデータを取得
    const { Items: items = [] } = await ddbDocClient.send(
      new ScanCommand({
        TableName: tableName,
      })
    )

    // データを1件取得できること
    expect(items.length).toBe(1)
  })
})

生成結果も1行違うだけですね。

先程と同じように

  • dynamodb-repeat-1.test.ts
  • dynamodb-repeat-100.test.ts
  • dynamodb-repeat-101.test.ts
  • dynamodb-repeat-102.test.ts

この4ファイルを作成します。

--runInBand なしで jest コマンドを実行します。先程は失敗していました。

結果、

Test Suites: 4 passed, 4 total
Tests:       304 passed, 304 total
Snapshots:   0 total
Time:        17.897 s

並列実行でもすべてのテストが成功しました。

まとめ

本記事で実施した方法は、冒頭紹介した記事の中の、

テストでのデータベース単位の捉えかた - 日々常々

「環境に閉じたデータベース」が選択できず、やむを得なく「開いたデータベース」を使用することもあるでしょう。 データベースサーバーやデータベースアプリケーションは共有しても、テストごとに独立したデータベースやスキーマを作って擬似的に「環境に閉じたデータベース」が作成できることもあります。可能ならばこれを選ぶのが良いでしょう。

こちらに近いアプローチです。擬似的な実現であっても十分実用性はありそうです。

ただ、今回の試行では「並列実行できるコード」も実行時間で見るとあまり速くなっていない点は気になります(26.441→17.897)。この点は、

  • さらにテストケース数、ファイル数を増やす
  • maxWorkersオプションを指定してさらに並列数を増やす
  • 実行端末の増強
  • LocalStackコンテナに割り当てるリソースを増強

などでより顕著に効果を見ることができるかもしれませんが、今後の調査課題としたいと思います。

参考