Lambda(Node.js)で、VitestのIn-Source Testingをする構成

2023.12.22

はじめに

Vitestには、In-Source Testingというテスト方法があります。これは実装とテストコードを同一のファイルに記述できます。Rustは言語レベルでこの機能をサポートしていたりします。

見栄えの感じ方は、個人差はありますがテストコードが小規模だとメリットを感じる人がいると思います。また、同一ファイルに書くことで、プライベート関数を公開せずにテストできます。Javaは、パッケージプライベートという可視性があり、似たようなことができます。

そもそもプライベート関数をテストするべきか?という議論がありますが、本記事ではテストする方法があることの紹介にフォーカスします。個々のプロジェクトが抱えている問題の解決策として有用であれば採用してみてください。

前提

構成

前提となる環境は以下です。

  • TypeScript
  • Lambda Node.jsランタイム(ts-nodeのESM実行対応が不安定なため、Nodeは18系を利用ています詳細は、後述のソースコード(.node-version)参照
  • npm workspaces構成(この構成でなくても、読み替えて頂ければ対応可能です)

ソースコード

実際に対応したリポジトリを公開しています。本記事では、重要な部分のみ解説します。利用しているライブラリのバージョンや全体観が分からない場合参照して頂ければと思います。

実装解説

実装+テストコード

以下のコードは、Lambdaのソースコードです。VitestのIn-Source Testingは、import.meta.vitestという記法を利用します。import.meta.vitestの中にテストを記載します。

packages/app/src/index.mts

export const handler = () => {
  const calc = add(10, 20);

  console.log(calc);
};

const add = (a: number, b: number) => {
  return a + b;
};

if (import.meta.vitest) {
  describe('addのテスト', () => {
    describe('1+1の場合', () => {
      test('Return 2', () => {
        const got = add(1, 1);

        expect(got).toEqual(2);
      });
    });
  });
}

ESM対応&Vitestの設定

import.meta記法はESMで利用可能で、commonjsでは利用できません。故にESM対応は必須となります。Lambda(Nodeランタイム)はESMに対応していますので、問題はありません。

拡張子の変更

.tsファイルは、すべて.mtsに変更します

package.jsonのtypeをmoduleに変更する

packages/app/src/index.mts

{
  "name": "app",
  "version": "0.1.0",
  "type": "module", // <- ESMにする
  ...(中略)
}

tsconfig.jsonをmodule指定をESMにする(not commonjs)

tsconfig.esm.json

{
  "compilerOptions": {
    "target": "es2022",
    "module": "es2022",
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "incremental": true
  },
  "ts-node": {
    "esm": true
  },
  "exclude": ["node_modules"]
}

前述のtsconfig.esm.jsonを継承する形で、作成します。

  • vitest/globalsは、vitestからdescribeやexpectをimportしなくてよくなる設定
  • vitest/importMetaは、In-Source Testingで必要な設定
  • ts-nodeでESMを実行する設定

packages/app/tsconfig.json

{
  "extends": "../../tsconfig.esm.json",
  "compilerOptions": {
    "esModuleInterop": true,
    "types": ["vitest/globals", "vitest/importMeta"]
  },
  "ts-node": {
    "esm": true
  }
}

Vitestの設定

packages/app/vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    name: 'unit',
    dir: 'src',
    globals: true, // vitestからdescribeやexpectをimportしなくてよくなる設定
    includeSource: ['./**/*.mts'], // mtsをテスト対象にする。dirでsrcを指定しているので、srcからの相対パスを指定することに注意
  },
});

テストの実行

ここまで実装すればテスト実行が可能です。

vitest run --config vitest.config.ts

実行結果

 RUN  v0.34.6 /Users/hoge/repos/github.com/shuntaka9576/lambda-vitest-template/packages/app

 ✓ |unit| src/index.mts (1)
   ✓ addのテスト (1)
     ✓ 1+1の場合 (1)
       ✓ Return 2

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  13:34:18
   Duration  367ms (transform 38ms, setup 0ms, collect 11ms, tests 2ms, environment 0ms, prepare 91ms)

ビルド&バンドル方法

テストコードはLambdaにデプロイしないようにする必要があります。今回はesbuildを利用して、import.meta.vitestの内容をデプロイするソースから削除する設定をします。

defineオプションで、import.meta.vitestの部分をif (void 0)に変換します。これだけだとifブロックに囲まれたソースコードはまだ残ってしまいますので、minifyオプションを有効化して、dead codeを削除します。

こちらの記事を参考にしています。

import esbuild from 'esbuild';

await esbuild.build({
  bundle: true,
  entryPoints: ['./src/index.mts'],
  outExtension: {
    '.js': '.mjs',
  },
  outfile: './dist/index.mjs',
  platform: 'node',
  format: 'esm',
  banner: {
    js: 'import { createRequire } from "module"; import url from "url"; const require = createRequire(import.meta.url); const __filename = url.fileURLToPath(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url));',
  },
  minify: true,
  define: {
    'import.meta.vitest': 'undefined',
  },
});

実際に以下のコマンドでビルド+バンドルします。

# lambda-vitest-template/packages/app ディレクトリで実行してください
ts-node ./build.ts

バンドル結果は以下の通りで、テストコードが存在しないことがわかります。

import { createRequire } from "module"; import url from "url"; const require = createRequire(import.meta.url); const __filename = url.fileURLToPath(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
var c=()=>{let t=o(10,20);console.log(t)},o=(t,e)=>t+e;export{c as handler};

CDK

CDKのコードは以下の通りです。NodejsFunctionは利用せず、前述のバンドル&ビルドコマンドを実行後、CDKのデプロイを実施します。

packages/iac/lib/main-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
type MainStackProps = StackProps;

export class MainStack extends Stack {
  constructor(scope: Construct, id: string, props: MainStackProps) {
    super(scope, id, props);

    new cdk.aws_lambda.Function(this, 'sampleLambda', {
      functionName: 'sample-lambda',
      runtime: cdk.aws_lambda.Runtime.NODEJS_18_X,
      code: cdk.aws_lambda.Code.fromAsset('../app/dist'),
      handler: 'index.handler',
      timeout: cdk.Duration.seconds(5),
    });
  }
}
# lambda-vitest-template/packages/iac ディレクトリで実行してください
$ npm run build -w app # ts-node ./build.tsが実行される
# ソースコードをLambdaへデプロイ
$ npx cdk deploy vitest-sample-lambda-stack

10+20=30の30が表示されているため、デプロイが正常に終了していることが確認できます。

最後に

ESM対応することで、VitestのIn-source Testingを機能を使うことができました。今のところプロジェクトで使う予定はありませんが、1つの手法として有用だと感じています。参考になれば幸いです。