😸

aws cdkの自動テストをvitestで実行する

2023/04/08に公開

動機

cdk プロジェクト(TypeScript)は立ち上げたときに jest(ts-jest)を使う形で自動テストの雛形が自動セットアップされている。
基本それをベースに改修していく形で困らない。

が、ts-jest は実行が遅かったりする。型チェックをしてくれるというのはあるが、cdk におけるテストでは恩恵はあまり多くない気もする。

TypeScript を利用する cdk プロジェクトではビルドに esbuild が使われるため、速度が問題であればesbuild-jestを使う手もある。が、執筆時点における最終アップデートが 2021 年 5 月だったりとメンテナンスが頻繁でなく心許なかったりする。

そこで、vitestを使ってみる。

検証環境

  • Fedora37
  • node v18.15.0(LTS)
  • pnpm@8.1.1
    • あまり本質ではないが今回はパッケージマネージャーに pnpm を使う
    • (npm や yarn を使う人は適当に読み替えてください)
  • aws-cdk@2.73.0

実行

(1)初期セットアップ

cdk プロジェクトを立ち上げる適当なディレクトリ(今回はcdk-appとする)を作成し、使用言語に TypeScript を指定して立ち上げる

# プロジェクトを立ち上げるディレクトリの作成
mkdir -p /path/to/cdk-app
cd /path/to/cdk-app

# プロジェクトの立ち上げ
npx aws-cdk@2.73.0 init --language typescript

rm package-lock.json # pnpmを使うため。npmを使う人はそのまま残せばOK
pnpm install # 使うpackageManagerに応じて適当に読み替え(以降も同様)

ここで、ディレクトリ構成は以下のような感じ

tree --gitignore
# .
# ├── README.md
# ├── bin
# │   └── cdk-app.ts
# ├── cdk.json
# ├── jest.config.js
# ├── lib
# │   └── cdk-app-stack.ts
# ├── package.json
# ├── pnpm-lock.yaml
# ├── test
# │   └── cdk-app.test.ts
# └── tsconfig.json
#
# 4 directories, 9 files

また、package.jsonは以下のようになっている。

package.json
{
  "name": "cdk-app",
  "version": "0.1.0",
  "bin": {
    "cdk-app": "bin/cdk-app.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@types/jest": "^29.4.0",
    "@types/node": "18.14.6",
    "jest": "^29.5.0",
    "ts-jest": "^29.0.5",
    "aws-cdk": "2.73.0",
    "ts-node": "^10.9.1",
    "typescript": "~4.9.5"
  },
  "dependencies": {
    "aws-cdk-lib": "2.73.0",
    "constructs": "^10.0.0",
    "source-map-support": "^0.5.21"
  }
}

次に、jest をアンインストールして vitest を導入する。

# jestのconfigを消去
rm jest.config.js
package.json
  "scripts": {
  // testコマンドをjestからvitestに切り替えておく
-   "test": "jest",
+   "test": "vitest --run",

  "devDependencies": {
  // jest関係のパッケージをdevDepenciesから消しておく
-   "@types/jest": "^29.4.0",
-   "jest": "^29.5.0",
-   "ts-jest": "^29.0.5",

vitest をインストールする

pnpm add -D vite vitest

次に vitest の config ファイルを作成する。

https://vitest.dev/config/

を参照しつつ、例えば以下のような感じで作る。

vitest.config.ts
import 'vitest/config';
import { defineConfig, type UserConfig } from 'vite';

export default defineConfig({
  test: {
    globals: true, // describeやtest, it, expectなどをimport無しで使えるようにする
    environment: 'node',
    include: ['./test/**/*.(test|spec).ts'], // 元のjestの設定が`test/`以下のファイルを参照する設定だったので合わせている
  },
});

これで、pnpm testを実行すると

pnpm test

#  RUN  v0.29.8 /home/wsl-user/tmp/cdk-tmp/cdk-app
#
#  ✓ test/cdk-app.test.ts (1)
#
#  Test Files  1 passed (1)
#       Tests  1 passed (1)
#    Start at  00:08:35
#    Duration  741ms (transform 46ms, setup 0ms, collect 12ms, tests 3ms)

のようにテストを自動で実行できる。

なお、vitest.config.tsにおいてglobals: trueを設定することで import 無しで test を実行できるようにしているが、このままだとエディタなどが vitest の型を読み込めずにエラー表示が出たりするので、必要であればtsconfig.jsonを以下のように設定する

tsconfig.json
// compilerOptionsで以下(types部分)を追記
  "compilerOptions": {
+   "types": [
+     "vitest/globals"
+   ],
  // ...

あるいは、各テストコードで

import { describe, test, expect, vi } from "vitest";

describe("***", () => {
  test("test-case-***", () => {
    // ...
  });
});

のように、明示的にvitestから import するようにする。

参考:

https://developer.mamezou-tech.com/blogs/2022/12/28/vitest-intro/

https://isub.co.jp/typescript/vitest-get-started/

(2)snapshot テストの導入

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/testing.html

https://pages.awscloud.com/rs/112-TZM-766/images/CDKでもテストがしたい.pdf

などで言及されているように、cdk における有効なテスト手法として

  • fine-grained assertions
  • snapshot testing

が挙げられている。

前者は特に vitest 固有の話は出て来ない?と思われるので、後者について少し取り上げる。

snapshot テストを実行するには、testディレクトリ以下に例えば次のようなテストコードファイル(test/snapshot.test.ts)を追加する。

test/snapshot.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkAppStack } from '../lib/cdk-app-stack';

test('snapshot test', () => {
  const app = new cdk.App();
  const stack = new CdkAppStack(app, 'TestStack');
  const template = Template.fromStack(stack).toJSON();

  expect(template).toMatchSnapshot();
});

これで

pnpm test

としてテストを実行すると snapshot テストが一緒に行われるようになり、__snapshots__以下にスナップショット内容が自動で保存される。

snapshot の内容のアップデート時は

pnpm test -- -u

などのようにすれば良い。

ここまででプロジェクトのディレクトリ・ファイル構成は以下のような感じ:

tree --gitignore
# .
# ├── README.md
# ├── bin
# │   └── cdk-app.ts
# ├── cdk.json
# ├── lib
# │   └── cdk-app-stack.ts
# ├── package.json
# ├── pnpm-lock.yaml
# ├── test
# │   ├── __snapshots__ ★自動生成
# │   │   └── snapshot.test.ts.snap ★自動生成
# │   ├── cdk-app.test.ts
# │   └── snapshot.test.ts ★追加
# ├── tsconfig.json
# └── vitest.config.ts
#
# 5 directories, 11 files

(...とここまでは別に vitest 固有の話は出て来ないが、以降の手順で vitest 特有の対処が必要な箇所が出てくるので順次紹介する)

asset(Lambda)の追加

esbuild でコンパイルが必要な asset として Lambda を追加し、cdk で管理するようにする。

簡単な例として、

pnpm add -D @types/aws-lambda esbuild

によりトランスパイル用の esbuild と Lambda 用の型定義をインストールしておき、その上で、
ディレクトリlambdaを作成し、その下にhello.tsなどの名前で Lambda のコードを追加する。

lambda/hello.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

export const lambdaHandler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  try {
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "hello world",
      }),
    };
  } catch (err) {
    console.log(err);
    return {
      statusCode: 500,
      body: JSON.stringify({
        message: "some error happened",
      }),
    };
  }
};

次に、lib/cdk-app-stack.tsを編集して Lambda をテンプレートに含める

lib/cdk-app-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
// import * as sqs from 'aws-cdk-lib/aws-sqs';
import { // 追加
  aws_lambda as lambda,
} from 'aws-cdk-lib';
import { // 追加
  NodejsFunction,
  OutputFormat,
  type NodejsFunctionProps,
} from 'aws-cdk-lib/aws-lambda-nodejs';

export class CdkAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here

    // example resource
    // const queue = new sqs.Queue(this, 'CdkAppQueue', {
    //   visibilityTimeout: cdk.Duration.seconds(300)
    // });

    // 以下のlambdaに関する部分を追加
    const sampleLambda = new NodejsFunction(this, 'test-lambda', {
      entry: './lambda/hello.ts',
      handler: 'lambdaHandler',
      runtime: lambda.Runtime.NODEJS_18_X,
    });
  }
}

これで

pnpm cdk synth

などとすれば Lambda のトランスパイル・アセット生成とテンプレート生成が完了する。

ただし、vitest を使う場合だと snapshot テストの際に esbuild でテンプレートを生成する部分がテスト中で失敗する。

https://github.com/aws/aws-cdk/issues/20873
https://github.com/vitest-dev/vitest/issues/1544
https://vitest.dev/config/#pool

などを見ると、vitest 実行時に thread を作らないようにvitestのデフォルトのスレッドプール(tinypoolを使用しているとのこと)を使わないようにすれば良さそう(2024/2/21修正)なので、
vitest.config.tsに以下の設定(pool: 'forks')を追加しておく。

vitest.config.ts
import 'vitest/config';
import { defineConfig, type UserConfig } from 'vite';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['./test/**/*.(test|spec).ts'],
+   pool: 'forks', // 追加
    // threads: false, // 追加 <- (vitestのアップデートに伴い、2024/2/21削除)
  },
});

これでvitestの snapshot test 実行時におけるエラーが解消される。

snapshot test における asset の変更の無視

インフラ構成は変えていないが、lambda など asset の内容を変えた際に snapshot test の実行結果が変わるのを避けたい場合が存在する。

https://dev.classmethod.jp/articles/aws-cdk-v2-unit-test-ignore-assets/

と同じこと(serializer を導入し、asset の hash 値を固定化する)をやれば OK だが、
vitest では全く同じようにはいかないので特有の対応を行う必要がある。

まずは ↑ の記事を参考にtest/plugins/ignore-asset-hash.tsを作成する。

test/plugins/ignore-asset-hash.ts
export const ignoreAssetHashSerializer = {
  test: (val: unknown) => typeof val === 'string',
  serialize: (val: string) => {
    return `"${val.replace(/([A-Fa-f0-9]{64}.zip)/, 'HASH-REPLACED.zip')}"`;
  },
};

Developers IO の記事では、
jest を使っているためjest.config.jsを編集して上記のプラグインをsnapshotSerializerに登録しているが、
今回は vitest を使っているため、異なる方法で登録する。

具体的には、snapshot test のコード test/snapshot.test.tsに以下のように追記する。

test/snapshot.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkAppStack } from '../lib/cdk-app-stack';
import { ignoreAssetHashSerializer } from './plugins/ignore-asset-hash'; // ★追加

test('snapshot test', () => {
  const app = new cdk.App();
  const stack = new CdkAppStack(app, 'TestStack');
  const template = Template.fromStack(stack).toJSON();

  // 次の行でプラグインをsnapshotSerializerに登録する
  expect.addSnapshotSerializer(ignoreAssetHashSerializer); // ★追加
  expect(template).toMatchSnapshot();
});

これで

pnpm test -- -u

などとすると snapshot test の結果がアップデートされ、今回の Lambda のトランスパイル結果に相当するアセット部分だと

// snapshottestの実行結果(Lambdaのasssetに関する部分)
-          "S3Key": "c35f1ac5d9b1bb43826a97c396fd7852dd4333c9414f1eaa527f5a0cd4e1994d.zip",
+          "S3Key": "HASH-REPLACED.zip",

のような形で、hash 値が固定文字列に置き換わっており、
Lambda のソースコードが書き換わっただけでは snapshot test の実行結果が変わらないようになった。

まとめ

最終的なディレクトリ・ファイル構成は以下のような感じ

tree --gitignore
# .
# ├── README.md
# ├── bin
# │   └── cdk-app.ts
# ├── cdk.json
# ├── lambda # ★ 追加
# │   └── hello.ts
# ├── lib
# │   └── cdk-app-stack.ts # ★編集: ランタイムがNodejsで動作するTypeScriptで記述されたLambdaのリソースを追加
# ├── package.json # packageのアンインストール・インストール + テスト実行のためのnpm-scriptsを編集
# ├── pnpm-lock.yaml # lockfile
# ├── test
# │   ├── __snapshots__
# │   │   └── snapshot.test.ts.snap # ★機械的に追加、アップデート
# │   ├── cdk-app.test.ts
# │   ├── plugins
# │   │   └── ignore-asset-hash.ts # ★追加
# │   └── snapshot.test.ts
# ├── tsconfig.json # ★編集: vitestの型情報を読み込む設定を追記
# └── vitest.config.ts # ★追加
#
# 7 directories, 13 files

微妙に設定が必要な箇所はあったが、jest を使う場合とほとんど同じように設定・実行することができた。

ただし、vitest における設定: threadsを false に設定しているため、
テストファイルが大量に必要なケースではパフォーマンス的に良くなかったり、テスト間の環境分離が不十分になることに起因する副作用だったりが起こる可能性もある。
そのような場合、若干面倒だが snapshot test だけ分離・独立させて行うのが良さそう。
(vitest cli のオプション: -cでコンフィグを別途指定できるので、snapshot test だけコンフィグファイルを分けるとか、あるいはvitest.config.ts内でうまく処理分岐をさせるなどの方法が考えられる)

(2024/2/21追記)
vitestのテスト並列実行方法の制御オプションがv1で追加されているため、vitestを使うとテストを並列実行できない問題は無くなっている。(該当箇所の記述は変更済み)
このとき、デフォルトの挙動から変更してnodeのchild_processを使ったプロセスのフォークによってテストの並列実行がなされる。
また、追記時点において、viteでcjs形式のbuildがdeprecatedになっているため、この記事で記載したやり方で作ったCDKプロジェクトでvitestを使うとwarningが出るようになる。(v6からはおそらくErrorになる)
そのため、プロジェクト全体をES Module形式に修正するなどの必要がある。

GitHubで編集を提案

Discussion