AWS Step Functionsステートマシンの実行イベント履歴のスナップショットテストをしてみた

2022.06.02

こんにちは、CX事業本部 IoT事業部の若槻です。

AWS Step FunctionsではGetExecutionHistory APIを使用することにより、ステートマシン実行のイベント履歴を取得できます。

実行イベント履歴は、ステートマシンの実行詳細の[Event View]で確認できる情報に該当します。この情報を評価することができれば、ステートマシンは期待通りに動作していることをテストできます。

今回は、ステートマシンの効率的なテスト方法を考えていたところ、GetExecutionHistory APIで実行イベント履歴を取得してスナップショットテストを行うをという手法を思いついたので、簡単にですが試してみました。

環境

$ npm list jest typescript @aws-sdk/client-sfn --depth=0
project@0.1.0 /Users/wakatsuki.ryuta/projects/project
├── @aws-sdk/client-sfn@3.100.0
├── jest@26.6.3
└── typescript@3.9.10

テスト対象のステートマシン

次の定義のステートマシンをテスト対象とします。入力に応じてパスが条件分岐します。

{
  "Comment": "A description of my state machine",
  "StartAt": "Choice",
  "States": {
    "Choice": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.value",
          "NumericEquals": 1,
          "Next": "Pass1"
        }
      ],
      "Default": "Pass2"
    },
    "Pass1": {
      "Type": "Pass",
      "End": true
    },
    "Pass2": {
      "Type": "Pass",
      "End": true
    }
  }
}

テスト

Jestで実行可能なテストコードです。ステートマシンを実際に実行し、実行履歴のスナップショットの評価を行います。

test/state-machine/state-machine-execution.test.ts

import {
  SFNClient,
  GetExecutionHistoryCommand,
  StartExecutionCommand,
  DescribeExecutionCommand,
  ExecutionStatus,
  HistoryEvent,
  GetExecutionHistoryCommandOutput,
} from '@aws-sdk/client-sfn';
import { writeFileSync, existsSync, readFileSync, fstat } from 'fs';

const STATE_MACHINE_ARN = process.env.STATE_MACHINE_ARN || '';
const snapshotFileName = 'state-machine-execution.test.snapshot.json';
const snapshotFileDir = 'test/state-machine/snapshots';

if (!existsSync(snapshotFileDir)) {
  throw new Error('Snapshot file dir is not found!');
}

const sfnClient = new SFNClient({ region: 'ap-northeast-1' });

//不定または未定義のフィールドを削除
const getProcessedEvents = (events: GetExecutionHistoryCommandOutput) => {
  const expectedEvents = events.events
    ?.map((d) => {
      delete d.timestamp;
      return d;
    })
    .sort((a, b) => {
      return (a.id as number) - (b.id as number);
    }) as HistoryEvent[];

  delete expectedEvents[0].executionStartedEventDetails?.roleArn;

  return JSON.parse(JSON.stringify(expectedEvents));
};

test('stateMachineExecutionSnapshotTest', async (): Promise<void> => {
  //ステートマシンを実行する
  const executionResult = await sfnClient.send(
    new StartExecutionCommand({
      stateMachineArn: STATE_MACHINE_ARN,
      input: JSON.stringify({ value: 2 }),
    })
  );
  const executionArn = executionResult.executionArn;

  //ステートマシンの実行が終了するまで待機
  let executionStatus = 'RUNNING';
  while (executionStatus === 'RUNNING') {
    await new Promise((r) => setTimeout(r, 1000));

    const res = await sfnClient.send(
      new DescribeExecutionCommand({
        executionArn: executionArn,
      })
    );

    if (res.status !== 'RUNNING')
      executionStatus = res.status as ExecutionStatus;
  }

  //実行のイベント履歴を取得
  const events = await sfnClient.send(
    new GetExecutionHistoryCommand({
      executionArn: executionArn,
      includeExecutionData: true,
    })
  );
  const processedEvents = getProcessedEvents(events);

  //スナップショットファイルが作成済みの場合/未作成の場合
  if (existsSync(`${snapshotFileDir}/${snapshotFileName}`)) {
    //作成済みの場合、ファイルからスナップショットを取得して実行履歴データとの一致を評価する。
    const snapshot = readFileSync(
      `${snapshotFileDir}/${snapshotFileName}`,
      'utf-8'
    );
    expect(processedEvents).toStrictEqual(JSON.parse(snapshot));
  } else {
    //未作成の場合、スナップショットファイルを作成する。
    writeFileSync(
      `${snapshotFileDir}/${snapshotFileName}`,
      JSON.stringify(processedEvents, undefined, 2)
    );
  }
});

テストを実行してみます。

$ npx jest test/state-machine/snapshots/state-machine-execution.test.snapshot.json

すると実行結果のスナップショットファイルが生成されました。実行履歴との一致を評価できるようにするため、timestamproleArnなどの不定なフィールドや、未定義のフィールドは生成前に削除するようにしています。

test/state-machine/snapshots/state-machine-execution.test.snapshot.json

[
  {
    "executionStartedEventDetails": {
      "input": "{\"value\":1}",
      "inputDetails": {
        "truncated": false
      }
    },
    "id": 1,
    "previousEventId": 0,
    "type": "ExecutionStarted"
  },
  {
    "id": 2,
    "previousEventId": 0,
    "stateEnteredEventDetails": {
      "input": "{\"value\":1}",
      "inputDetails": {
        "truncated": false
      },
      "name": "Choice"
    },
    "type": "ChoiceStateEntered"
  },
  {
    "id": 3,
    "previousEventId": 2,
    "stateExitedEventDetails": {
      "name": "Choice",
      "output": "{\"value\":1}",
      "outputDetails": {
        "truncated": false
      }
    },
    "type": "ChoiceStateExited"
  },
  {
    "id": 4,
    "previousEventId": 3,
    "stateEnteredEventDetails": {
      "input": "{\"value\":1}",
      "inputDetails": {
        "truncated": false
      },
      "name": "Pass1"
    },
    "type": "PassStateEntered"
  },
  {
    "id": 5,
    "previousEventId": 4,
    "stateExitedEventDetails": {
      "name": "Pass1",
      "output": "{\"value\":1}",
      "outputDetails": {
        "truncated": false
      }
    },
    "type": "PassStateExited"
  },
  {
    "executionSucceededEventDetails": {
      "output": "{\"value\":1}",
      "outputDetails": {
        "truncated": false
      }
    },
    "id": 6,
    "previousEventId": 5,
    "type": "ExecutionSucceeded"
  }
]

このスナップショットファイルはGitで管理し、ステートマシンの実装や入力に変更があるたびに合わせて更新することになります。

再度テストを実行すると、スナップショットと実行履歴の一致評価が行われ、PASSしました。

$  npx jest
 PASS  test/state-machine/state-machine-execution.test.ts
  ✓ stateMachineExecutionSnapshotTest (1346 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.657 s, estimated 7 s
Ran all test suites.

次にステートマシン実行時の入力を{ value: 2 }へ変更してみます。

test/state-machine/state-machine-execution.test.ts

test('stateMachineExecutionSnapshotTest', async (): Promise<void> => {
  //ステートマシンを実行する
  const executionResult = await sfnClient.send(
    new StartExecutionCommand({
      stateMachineArn: STATE_MACHINE_ARN,
      input: JSON.stringify({ value: 2 }),
    })
  );

テストを実行すると、想定通りスナップショットとの不一致のため実行がFAILしました。

$  npx jest
 FAIL  test/state-machine/state-machine-execution.test.ts (6.984 s)
  ✕ stateMachineExecutionSnapshotTest (1335 ms)

  ● stateMachineExecutionSnapshotTest

    expect(received).toStrictEqual(expected) // deep equality

    - Expected  - 8
    + Received  + 8

    @@ -1,9 +1,9 @@
      Array [
        Object {
          "executionStartedEventDetails": Object {
    -       "input": "{\"value\":1}",
    +       "input": "{\"value\":2}",
            "inputDetails": Object {
              "truncated": false,
            },
          },
          "id": 1,
    @@ -12,11 +12,11 @@
        },
        Object {
          "id": 2,
          "previousEventId": 0,
          "stateEnteredEventDetails": Object {
    -       "input": "{\"value\":1}",
    +       "input": "{\"value\":2}",
            "inputDetails": Object {
              "truncated": false,
            },
            "name": "Choice",
          },
    @@ -25,44 +25,44 @@
        Object {
          "id": 3,
          "previousEventId": 2,
          "stateExitedEventDetails": Object {
            "name": "Choice",
    -       "output": "{\"value\":1}",
    +       "output": "{\"value\":2}",
            "outputDetails": Object {
              "truncated": false,
            },
          },
          "type": "ChoiceStateExited",
        },
        Object {
          "id": 4,
          "previousEventId": 3,
          "stateEnteredEventDetails": Object {
    -       "input": "{\"value\":1}",
    +       "input": "{\"value\":2}",
            "inputDetails": Object {
              "truncated": false,
            },
    -       "name": "Pass1",
    +       "name": "Pass2",
          },
          "type": "PassStateEntered",
        },
        Object {
          "id": 5,
          "previousEventId": 4,
          "stateExitedEventDetails": Object {
    -       "name": "Pass1",
    -       "output": "{\"value\":1}",
    +       "name": "Pass2",
    +       "output": "{\"value\":2}",
            "outputDetails": Object {
              "truncated": false,
            },
          },
          "type": "PassStateExited",
        },
        Object {
          "executionSucceededEventDetails": Object {
    -       "output": "{\"value\":1}",
    +       "output": "{\"value\":2}",
            "outputDetails": Object {
              "truncated": false,
            },
          },
          "id": 6,

      80 |       'utf-8'
      81 |     );
    > 82 |     expect(processedEvents).toStrictEqual(JSON.parse(snapshot));
         |                             ^
      83 |   } else {
      84 |     writeFileSync(
      85 |       `${snapshotFileDir}/${snapshotFileName}`,

      at Object.<anonymous> (test/state-machine/state-machine-execution.test.ts:82:29)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        7.029 s
Ran all test suites.

複数パターンのテストを行いたい場合

今回は単一のパターンでのスナップショットテストを作成しましたが、テストケースやファイルを分ければ、ステートマシンに対して複数パターンの入力に対するテストを行うことも可能です。その場合はtest.eachを使うと便利かと思います。

example

test.each([
  [1, 1, 2],
  [1, 2, 3],
  [2, 1, 3],
])('.add(%i, %i)', (a, b, expected) => {
  expect(a + b).toBe(expected);
});

おわりに

AWS Step Functionsステートマシンの実行のスナップショットテストをしてみました。

普通にすごく便利なのでステートマシンの結合テストは今後はこれで良いんじゃないかと思いました。現在対応中の案件で早速導入したいと思います。

参考

以上