AWS CDK のスナップショットテストで AWS Step Functions の ASL 差分が見づらい問題の対応を考える

記事タイトルとURLをコピーする

はじめに

サーバーワークスの宮本です。今回は AWS CDK で AWS Step Functions を構築する場合に発生するちょっとした問題の対応について考えてみました。

対象読者

  • AWS CDK を TypeScript で書いている人
  • Step Functions を扱っている人
  • スナップショットテストを導入している人

最初に結論

以下ページを参考にセットアップしていただくと Step Functions の定義部分 (ASL) のスナップショット差分がいい感じに出力されるようになります。(一部課題あり)

AWS CDK (TypeScript) で Step Functions を構築する場合にスナップショットの差分が見づらい問題の対処 · GitHub

課題

AWS CDK で Step Functions を構築すると、定義部分 (ASL) の差分が見にくいという問題があります。具体例を挙げて見ていきましょう。

以下のような Step Functions を含むスタックがあります。(サンプルのため内容は適当です)

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as sfn from "aws-cdk-lib/aws-stepfunctions";
  
export class SfnSnapshotStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
  
    new sfn.StateMachine(this, 'MyStateMachine', {
      definitionBody: sfn.DefinitionBody.fromChainable(
        new sfn.Wait(this, 'WaitState1', {
          time: sfn.WaitTime.duration(cdk.Duration.seconds(10)),
        })
        .next(
          new sfn.Wait(this, 'WaitState2', {
            time: sfn.WaitTime.duration(cdk.Duration.seconds(20)),
          })
        )
      )
    });
  }
}

初回のスナップショットを保存した状態で、Step Functions の定義を一部変更、スナップショットテストを実行すると以下の通り出力されました。

❯ npx jest
 FAIL  test/sfn-snapshot.test.ts
  ✕ Snapshot test (146 ms)

  ● Snapshot test

    expect(received).toMatchSnapshot()

    Snapshot name: `Snapshot test 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -11,11 +11,11 @@
            "DeletionPolicy": "Delete",
            "DependsOn": [
              "MyStateMachineRoleD59FFEBC",
            ],
            "Properties": {
    -         "DefinitionString": "{"StartAt":"WaitState1","States":{"WaitState1":{"Type":"Wait","Seconds":10,"Next":"WaitState2"},"WaitState2":{"Type":"Wait","Seconds":20,"End":true}}}",
    +         "DefinitionString": "{"StartAt":"WaitState1","States":{"WaitState1":{"Type":"Wait","Seconds":10,"Next":"WaitState2"},"WaitState2":{"Type":"Wait","Seconds":30,"End":true}}}",
              "RoleArn": {
                "Fn::GetAtt": [
                  "MyStateMachineRoleD59FFEBC",
                  "Arn",
                ],

       8 |
       9 |   const stackTemplate = Template.fromStack(stack).toJSON();
    > 10 |   expect(stackTemplate).toMatchSnapshot();
         |                         ^
      11 | });
      12 |

      at Object.<anonymous> (test/sfn-snapshot.test.ts:10:25)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or run `npm run npx -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        2.978 s, estimated 3 s
Ran all test suites.

差分が 15, 16 行目に表示されていますが、どこが変更されたかわかりにくいですね。これはプロパティ DefinitionString の Type が String であり、そこに JSON 形式で ASL が設定されるためです。

対応策

Jest の Snapshot Serializer を使用して対応します。Snapshot Serializer は、スナップショットの内容を整形して比較しやすくするためのプラグイン機構です。

jestjs.io

以下の通り Serializer を実装しました。test メソッドで変換対象の条件を定義し、serialize メソッドで変換のカスタムロジックを定義します。17 行目で、DefinitionString の JSON 文字列を Parse してオブジェクトに変換することで、差分が見やすくなるようにしています。

// test/sfn-asl-serializer.ts
import { Plugin } from 'pretty-format';
  
const serializer: Plugin = {
  test: (val) => {
    return (
      val !== null &&
      typeof val === 'object' &&
      !Array.isArray(val) &&
      Object.prototype.hasOwnProperty.call(val, 'DefinitionString') &&
      typeof val.DefinitionString === 'string'
    );
  },
  serialize: (val, config, indentation, depth, refs, printer) => {
    const newVal = {
      ...val,
      DefinitionString: JSON.parse(val.DefinitionString),
    };
    return printer(newVal, config, indentation, depth, refs);
  },
};
  
module.exports = serializer;

Serializer が起動するように設定を追加します。

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/test'],
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  },
  // この設定を追加
  snapshotSerializers: ['<rootDir>/test/sfn-asl-serializer.ts'],
};

先ほどと同様の条件で、スナップショットテストを実行します。

❯ npx jest
 FAIL  test/sfn-snapshot.test.ts
  ✕ Snapshot test (113 ms)

  ● Snapshot test

    expect(received).toMatchSnapshot()

    Snapshot name: `Snapshot test 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -21,11 +21,11 @@
                    "Seconds": 10,
                    "Type": "Wait",
                  },
                  "WaitState2": {
                    "End": true,
    -               "Seconds": 20,
    +               "Seconds": 30,
                    "Type": "Wait",
                  },
                },
              },
              "RoleArn": {

       8 |
       9 |   const stackTemplate = Template.fromStack(stack).toJSON();
    > 10 |   expect(stackTemplate).toMatchSnapshot();
         |                         ^
      11 | });
      12 |

      at Object.<anonymous> (test/sfn-snapshot.test.ts:10:25)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or run `npm run npx -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        2.465 s, estimated 5 s
Ran all test suites.

15, 16 行目が差分です。変更点がわかりやすくなりました。 なお、スナップショットの全体像は以下の通りです。18 行目の DefinitionStringString ではなく Object として保存されていることがわかります。CloudFormation の定義としては正しい状態ではありませんが、スナップショット差分を見やすくするという観点では良いのではないでしょうか。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Snapshot test 1`] = `
{
  "Parameters": {
    "BootstrapVersion": {
      "Default": "/cdk-bootstrap/hnb659fds/version",
      "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]",
      "Type": "AWS::SSM::Parameter::Value<String>",
    },
  },
  "Resources": {
    "MyStateMachine6C968CA5": {
      "DeletionPolicy": "Delete",
      "DependsOn": [
        "MyStateMachineRoleD59FFEBC",
      ],
      "Properties": {
        "DefinitionString": {
          "StartAt": "WaitState1",
          "States": {
            "WaitState1": {
              "Next": "WaitState2",
              "Seconds": 10,
              "Type": "Wait",
            },
            "WaitState2": {
              "End": true,
              "Seconds": 20,
              "Type": "Wait",
            },
          },
        },
        "RoleArn": {
          "Fn::GetAtt": [
            "MyStateMachineRoleD59FFEBC",
            "Arn",
          ],
        },
      },
      "Type": "AWS::StepFunctions::StateMachine",
      "UpdateReplacePolicy": "Delete",
    },
    "MyStateMachineRoleD59FFEBC": {
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Statement": [
            {
              "Action": "sts:AssumeRole",
              "Effect": "Allow",
              "Principal": {
                "Service": "states.amazonaws.com",
              },
            },
          ],
          "Version": "2012-10-17",
        },
      },
      "Type": "AWS::IAM::Role",
    },
  },
  "Rules": {
    "CheckBootstrapVersion": {
      "Assertions": [
        {
          "Assert": {
            "Fn::Not": [
              {
                "Fn::Contains": [
                  [
                    "1",
                    "2",
                    "3",
                    "4",
                    "5",
                  ],
                  {
                    "Ref": "BootstrapVersion",
                  },
                ],
              },
            ],
          },
          "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.",
        },
      ],
    },
  },
}
`;

新たな課題

めでたしめでたし... と言いたいところですが、手元のプロジェクトに適用して見たところ、意図通りの変換を行ってくれないケースがありました。スナップショットを見てみると、DefinitionStringString ではなく、以下のような Object になっていることを確認しました。

// 前略
      "Properties": {
        "DefinitionString": {
          "Fn::Join": [
            "",
            [
              "{"StartAt":"FooLambdaTask","States":{"FooLambdaTask":{"Next":"calculateWaitSeconds","Retry":[{"ErrorEquals":["Lambda.ClientExecutionTimeoutException","Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2}],"Type":"Task","ResultPath":null,"Resource":"arn:",
              {
                "Ref": "AWS::Partition",
              },
              ":states:::lambda:invoke","Parameters":{"FunctionName":"",
              {
                "Fn::GetAtt": [
                  "FooLambdaFunction315BD5ED",
                  "Arn",
                ],
              },
// 以下略

CloudFormation の組み込み関数 Fn::Join が使用されるパターンです。擬似パラメータ AWS::Partition や、組み込み関数 Fn::GetAtt を使用するため、単純な JSON 文字列にならないのです。

対応策(改)

以下が Fn::Join が使用されるパターンにも対応した Serializer です。covertToParsableJsonString メソッドで Fn::Join が含まれる文字列を JSON として Parse 可能な文字列に変換します。擬似パラメータ AWS::Partition や、組み込み関数 Fn::GetAtt オブジェクトはやや強引ですが JSON 文字列に変換してしまいます。

import { Plugin } from 'pretty-format';

const serializer: Plugin = {
  test: (val) => {
    return (
      val !== null &&
      typeof val === 'object' &&
      !Array.isArray(val) &&
      Object.prototype.hasOwnProperty.call(val, 'DefinitionString') &&
      (typeof val.DefinitionString === 'string' ||
        Object.prototype.hasOwnProperty.call(val.DefinitionString, 'Fn::Join'))
    );
  },
  serialize: (val, config, indentation, depth, refs, printer) => {
    let jsonString = val.DefinitionString;
    if (Object.prototype.hasOwnProperty.call(val.DefinitionString, 'Fn::Join')) {
      // Fn::Join を含むオブジェクトの場合は JSON として parse 可能な文字列に変換する
      jsonString = convertToParsableJsonString(val.DefinitionString);
    }
    const newVal = {
      ...val,
      DefinitionString: JSON.parse(jsonString),
    };
    return printer(newVal, config, indentation, depth, refs);
  },
};

const convertToParsableJsonString = (definitionString: { 'Fn::Join': [string, [string | object]] }) => {
  return definitionString['Fn::Join'][1]
    .map((v: unknown) => {
      if (typeof v === 'string') {
        // 文字列の場合はそのまま
        return v;
      } else {
        // 文字列以外 (擬似パラメータ `AWS::Partition` 、組み込み関数 `Fn::GetAtt` オブジェクトなど)の場合は文字列に変換
        try {
          return JSON.stringify(v).replace(/"/g, '\\"');
        } catch {
          console.warn(`Could not stringify value ${v} in DefinitionString returning 'couldnotstringified' instead.`);
          return 'couldnotstringified';
        }
      }
    })
    .join('');
};

module.exports = serializer;

スナップショットは以下のように出力されます。擬似パラメータ AWS::Partition は 26 行目、組み込み関数 Fn::GetAtt オブジェクトは 33 行目の様に変換されます。こちらも CloudFormation 定義としては正しくありませんが、差分確認の観点では問題ないと考えます。

// 前略
      "Properties": {
        "DefinitionString": {
          "StartAt": "FooLambdaTask",
          "States": {
            "AllComplete": {
              "Type": "Succeed",
            },
            "BarLambdaTask": {
              "Catch": [
                {
                  "ErrorEquals": [
                    "States.ALL",
                  ],
                  "Next": "BarTaskFailed",
                  "ResultPath": "$.Error",
                },
              ],
              "Next": "BazLambdaTask",
              "Parameters": {
                "Input": {
                  "hoge.$": "$.fuga",
                },
                "StateMachineArn": "arn:aws:states:ap-northeast-1:123456789012:stateMachine:DummyStateMachine",
              },
              "Resource": "arn:{"Ref":"AWS::Partition"}:states:::states:startExecution.sync:2",
              "ResultPath": "$.BarLambdaTaskResult",
              "Type": "Task",
            },
            "PiyoLambdaTask": {
              "Next": "MyChoice",
              "Parameters": {
                "FunctionName": "{"Fn::GetAtt":["PiyoLambdaFunction5FD2007F","Arn"]}",
                "Payload.$": "$",
              },
// 後略

概ねうまくいきそうですが、一点課題があります。スナップショットの States プロパティ配下のステート順序が CDK で記載した順番にならないことです。これは Serializer の実装で JSON.parse を使用しているためです。JSON.parse はキーの順序を保証しないため、意図しないステートの順番となってしまいます。各ステート内のプロパティを変更する程度であれば、順序は変わらなさそうですが、新規のステートが追加された時などは必要以上の差分が発生してしまう(ステートの中身は同じだが順番が入れ替わってしまうなど)こともあるかもしれません。対策を更に考えてみたいところですが本記事はここまでとします。

おわりに

AWS CDK のスナップショットテストで AWS Step Functions の ASL 差分が見づらい問題の対応を考えてみました。同じ課題感をお持ちの方の参考になれば幸いです。