Step Functions Localのモック統合をJestで自動テストするためのプラクティス

# はじめに
2022年1月31日に、Step Functions Localでモックを使えるようになったことが発表された。

- [AWS Step Functions でローカルなワークフローを模擬的にテストすることが可能に](https://aws.amazon.com/jp/about-aws/whats-new/2022/01/aws-step-functions-support-workflows/)

これで、ますますLowCode開発が捗るようになるかと思いきや、[公式ブログ](https://aws.amazon.com/jp/blogs/compute/mocking-service-integrations-with-aws-step-functions-local/)とかでもAWS CLIを使っていて、せっかくのモックなのに自動テストのやり方に言及されていないので、少し深堀りしてプラクティスを考える。

本記事では、[以前の記事](https://qiita.com/neruneruo/items/500679bb9fb865e72d9e)で作ったステートマシンについて、Lambdaをモック化してJestを使った自動テストをする。ステートマシンのイメージは下図の通りだ。

![stepfunctions_graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/b05950ed-3154-8000-c578-957edb136376.png)

# 準備
テストコードを書くにあたって、Jestを準備しよう。
以下のようにpackage.jsonを用意して、```npm install```する。
別に大したことはしてないが、要はAWS SDKとLinter(ESLint)とJestをインストールしたいだけだ。

```json:package.json
{
  "devDependencies": {
    "aws-sdk": "^2.1073.0",
    "eslint": "^8.8.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-standard": "^16.0.3",
    "eslint-plugin-import": "^2.25.4",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-promise": "^6.0.0",
    "eslint-standard": "^1.0.2",
    "jest": "^27.5.1",
    "prettier": "^2.5.1"
  },
  "scripts": {
    "lint": "eslint",
    "test": "jest"
  }
}
```

# モックの定義
モックの細かい定義方法は[公式の開発者ガイド](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/sfn-local-test-sm-exec.html)を見てもらうのが良いだろう。

今回は、RandomのLambdaについて、ランダムではなくてOKとNGを固定で返すモック応答の```RandomOK```と```RandomNG```を作成し、OKPath/NGPathのテストケースでそれぞれ呼び出す。StudentUpdateについては、今回は正常終了固定で動かしてみる。

なお、```Expects```は公式のモックのサンプルには定義されていない。
これは、せっかくテストを自動化するからにはassertionをしたいし、assertionするからにはテストケースごとに期待結果を定義したいためだ。ファイルを分割するのは面倒だし、見通しをよくするためにもテストケースと同じ並びに定義したい。今回は、ペイロードの内容を比較できるように定義する。

```json:StepFunctionsLocalMock.json
{
  "StateMachines":{
    "StudentUpdate":{
      "TestCases":{
        "OKPath":{
          "Random":"RandomOK",
          "StudentUpdate":"StudentUpdateOK"
        },
        "NGPath":{
          "Random":"RandomNG",
          "StudentUpdate":"StudentUpdateOK"
        }
      },
      "Expects":{
        "OKPath":{
          "RandomResult":{
            "Payload":{
              "StatusCode":200,"result":"OK"
            }
          } 
        },
        "NGPath":{
          "RandomResult":{
            "Payload":{
              "StatusCode":200,"result":"NG"
            }
          },
          "StudentUpdateResult":{
            "Payload":{
              "StatusCode":200,"result":"OK"
            }
          } 
        }
      }
    }
  },
  "MockedResponses":{
    "RandomOK":{
      "0":{
        "Return":{
          "StatusCode":200,
          "Payload":{
            "StatusCode":200,
            "result": "OK"
          }
        }
      }
    },
    "RandomNG":{
      "0":{
        "Return":{
          "StatusCode":200,
          "Payload":{
            "StatusCode":200,
            "result": "NG"
          }
        }
      }
    },
    "StudentUpdateOK":{
      "0":{
        "Return":{
          "StatusCode":200,
          "Payload":{
            "StatusCode":200,
            "result": "OK"
          }
        }
      }
    }
  }
}
```

# Step Functions LocalをDockerで起動する
モック定義ができたら、Step Functions Localにインプットして```docker-compose up```しよう。
docker-composeのvolumesでファイルを渡して、環境変数```SFN_MOCK_CONFIG```でコンテナ側のパスを指定する。

それ以外の環境変数は今回のケースでは設定しなくても動作するが、起動時にエラーログが出て気持ちが悪いので、ダミーを設定しておく。
※SAM LocalやDynamoDB Local、はたまた本物のAWSのサービスと連動する場合は必要になる設定である。

```YAML:docker-compose.yml
version: '3'
services:
  aws-stepfunctions-local:
    image: amazon/aws-stepfunctions-local
    container_name: aws-stepfunctions-local
    ports:
      - 8083:8083
    volumes:
      - type: bind
        source: ./StepFunctionsLocalMock.json
        target: /home/StepFunctionsLocal/MockConfigFile.json
    environment:
      - SFN_MOCK_CONFIG=/home/StepFunctionsLocal/MockConfigFile.json
      - AWS_ACCOUNT_ID=123456789012
      - AWS_DEFAULT_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=dummy
      - AWS_SECRET_ACCESS_KEY=dummy
```

# テストコードの作成
テストコードの全体像は以下の通りだ。
ポイント別に説明をしていく。

```test.js
const AWS = require('aws-sdk')
const fs = require('fs')

const sfn = new AWS.StepFunctions({
  region: 'us-east-1',
  endpoint: 'http://localhost:8083'
})

const STATE_MACHINE_NAME = 'StudentUpdate'
const STATE_MACHINE_DEFINITION_FILE = './terraform/16_3_stepfunctions_student_update.json'
const STEPFUNCTIONS_LOCAL_MOCK_FILE = './StepFunctionsLocalMock.json'
const DUMMY_IAM_ROLE_ARN = 'arn:aws:iam::012345678901:role/DummyRole'
let stateMachineArn = ''
const testCases = []

describe('全テストケースの実施', () => {
  // テストケースと期待結果のロード(test.each()のテーブル評価がbeforeAll()よりも早く行われるため、ここで実施)
  const mockDefinition = JSON.parse(fs.readFileSync(STEPFUNCTIONS_LOCAL_MOCK_FILE).toString())
  const mockStateMachines = Reflect.get(mockDefinition.StateMachines, STATE_MACHINE_NAME)
  for (const testCase in mockStateMachines.TestCases) {
    const expect = Reflect.get(mockStateMachines.Expects, testCase)
    const testCaseArray = [{ caseName: testCase, expect: expect }]
    testCases.push(testCaseArray)
  }

  // 初期化
  beforeAll(async () => {
    // テスト対象のステートマシンの作成
    const stateMachineDefinition = fs.readFileSync(STATE_MACHINE_DEFINITION_FILE)
    const result = await sfn
      .createStateMachine({
        name: STATE_MACHINE_NAME,
        definition: stateMachineDefinition.toString(),
        roleArn: DUMMY_IAM_ROLE_ARN
      })
      .promise()
    stateMachineArn = result.stateMachineArn
  })

  // テスト実行
  test.each(
    testCases
  )('ステートマシンの実行が成功することを確認(%s)', async (testCase) => {
    // テスト対象のステートマシンを実行
    const executionResult = await sfn
      .startExecution({
        stateMachineArn: stateMachineArn + '#' + testCase.caseName
      })
      .promise()
    const executionArn = executionResult.executionArn

    // ステートマシンの実行情報を取得
    let stateMachineStatus = 'RUNNING'
    while (stateMachineStatus === 'RUNNING') {
      await new Promise((resolve) => setTimeout(resolve, 500))

      const executionDescription = await sfn
        .describeExecution({
          executionArn: executionArn
        })
        .promise()
      stateMachineStatus = executionDescription.status
    }

    const executionHistory = await sfn
      .getExecutionHistory({
        executionArn: executionArn,
        reverseOrder: true,
        maxResults: 1
      })
      .promise()

    // ステートマシンの実行が成功していることを確認
    expect(stateMachineStatus).toBe('SUCCEEDED')
    // 出力結果のペイロードの確認
    for (const item in testCase.expect) {
      const receivedResult = Reflect.get(JSON.parse(executionHistory.events[0].executionSucceededEventDetails.output), item)
      const expectedResult = Reflect.get(testCase.expect, item)

      expect(receivedResult.Payload).toStrictEqual(expectedResult.Payload)
    }
  })
})
```

## 初期化① テストケースと期待結果のロード
テストケースと期待結果は、別で管理すると漏れにつながるので、モック定義ファイルから読み取って自動で全部を実行するようにする。
Jestにはtest.eachといい、配列をループして実行する仕組みがあるので、以下のようにテストケース名と期待結果を配列に詰め込む。
なお、beforeAll()の初期化処理でこれを実行しないのは、Jestの変数評価順序として、test.each()に食わせる配列の評価がbeforeAll()より前に行われるらしいためだ(実際に、beforeAll()にこの処理を実施しても、配列がNULLになってしまいうまく動作しない)

```javascript
  // テストケースと期待結果のロード(test.each()のテーブル評価がbeforeAll()よりも早く行われるため、ここで実施)
  const mockDefinition = JSON.parse(fs.readFileSync(STEPFUNCTIONS_LOCAL_MOCK_FILE).toString())
  const mockStateMachines = Reflect.get(mockDefinition.StateMachines, STATE_MACHINE_NAME)
  for (const testCase in mockStateMachines.TestCases) {
    const expect = Reflect.get(mockStateMachines.Expects, testCase)
    const testCaseArray = [{ caseName: testCase, expect: expect }]
    testCases.push(testCaseArray)
  }
```

## 初期化② ステートマシンの作成
作ったばかりのStep Functions Localは当然のことながら空っぽなので、AWS SDKを通じて定義を作る。
今回は、もともとStep FunctionsをTerraformで作っていたので、定義のJSONをTerraformのディレクトリから引っ張ってきている。

```javascript
  beforeAll(async () => {
    // テスト対象のステートマシンの作成
    const stateMachineDefinition = fs.readFileSync(STATE_MACHINE_DEFINITION_FILE)
    const result = await sfn
      .createStateMachine({
        name: STATE_MACHINE_NAME,
        definition: stateMachineDefinition.toString(),
        roleArn: DUMMY_IAM_ROLE_ARN
      })
      .promise()
    stateMachineArn = result.stateMachineArn
  })
```

## テストの実行
以下の通り```startExecution()```で起動する。
今回のケースはすぐに実行完了するので問題ないが、完了までにタイムラグのあるステートマシンを起動する場合に備えて、```executionDescription```の結果が```RUNNING```でなくなるまで待機するようにしておく。
※なお、startSyncExecution()には対応していない……らしい。タイムアウトする。調べてみてもイマイチよく分からなかった。

```javascript
  // テスト実行
  test.each(
    testCases
  )('ステートマシンの実行が成功することを確認(%s)', async (testCase) => {
    // テスト対象のステートマシンを実行
    const executionResult = await sfn
      .startExecution({
        stateMachineArn: stateMachineArn + '#' + testCase.caseName
      })
      .promise()
    const executionArn = executionResult.executionArn

    // ステートマシンの実行情報を取得
    let stateMachineStatus = 'RUNNING'
    while (stateMachineStatus === 'RUNNING') {
      await new Promise((resolve) => setTimeout(resolve, 500))

      const executionDescription = await sfn
        .describeExecution({
          executionArn: executionArn
        })
        .promise()
      stateMachineStatus = executionDescription.status
    }
```

## 結果の確認
以下のように```executionHistory()```から結果を取得して、ペイロードを確認していく。
executionHistory()は結果をいっぱい返してきてウザいので、```reverseOrder```を設定して```maxResults```で最後の状態だけ確認する。基本的に、↑で待ち合わせをしているので、以下のような結果が得られるはずだ。

```JSON
    {
      events: [
        {
          timestamp: 2022-02-13T05:27:16.252Z,
          type: 'ExecutionSucceeded',
          id: 14,
          previousEventId: 13,
          executionSucceededEventDetails: [Object]
        }
      ],
      nextToken: '1'
    }
```

今回は雑にハンドリングしているが、もう少し厳密に```ExecutionSucceeded```を見た方がより精度が上がるだろう。
ペイロードは、初期化①時に作ったペイロードと比較をする。

```javascript
    const executionHistory = await sfn
      .getExecutionHistory({
        executionArn: executionArn,
        reverseOrder: true,
        maxResults: 1
      })
      .promise()

    // ステートマシンの実行が成功していることを確認
    expect(stateMachineStatus).toBe('SUCCEEDED')
    // 出力結果のペイロードの確認
    for (const item in testCase.expect) {
      const receivedResult = Reflect.get(JSON.parse(executionHistory.events[0].executionSucceededEventDetails.output), item)
      const expectedResult = Reflect.get(testCase.expect, item)

      expect(receivedResult.Payload).toStrictEqual(expectedResult.Payload)
    }
  })
```

これで、Step Functions Localの自動テストができるようになった。
ここまでやればCI/CDに組み込むことも容易であろう。