TypeScript × CDK エンジニア向け:Jest テスト時の ESModule 対応 — 2つの実践的アプローチ

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

アプリケーションサービス本部の鎌田 (義) です。
CDK + TypeScript で開発する際、テストツールには Jest が標準で使われるのですが、 Jest でのテスト時に ESModule に関連するエラーと遭遇し、解決に苦労したことがあったので共有します。

何があったか

Lambda にデプロイしているコード内で middy というライブラリを使っており、 v4 から v6 に更新したところ、Jest が以下のようなエラーを吐くようになりました。

 FAIL  test/handler/example1.test.ts
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    /<PROJECT_PATH>/node_modules/@middy/core/index.js:2
    import { Readable } from 'node:stream'
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

      3 | import { parser } from "@aws-lambda-powertools/parser/middleware";
      4 | import * as z from "zod";
    > 5 | import middy from "@middy/core";
        | ^
      6 | import inputOutputLogger from "@middy/input-output-logger";
      7 | import { Logger } from "@aws-lambda-powertools/logger";
      8 |

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14)
      at Object.<anonymous> (src/handler/example1.ts:5:1)
      at Object.<anonymous> (test/handler/example1.test.ts:2:1)

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

Jest がファイルの解析に失敗しており、 エラー内容でググってみると、Jest では、デフォルトで CommonJs として扱うようです。

jestjs.io

middy を使用している箇所でエラーが出力されていますが、 middy では、Version4 までは CommonJs と ESModule の両方に対応していましたが、 Version5 からは CommonJs をサポートしなくなった影響でエラーが発生するようになりました。

middy.js.org

ログにBy default "node_modules" folder is ignored by transformers.と出力されている通り、 node_modules 配下はトランスパイルの対象外のようなので 対応方針としては、middy モジュール を ESModule から CommonJs へ変換してやれば良さそうです。

CommonJs と ESModule については、Node.js のドキュメントを参照下さい。

nodejs.org

対応方法

  1. 対象のモジュール (middy) を CommonJs にトランスパイルする
  2. CDK アプリケーション全体で ESModule に統一する

CDK はじめ、バックエンド (Node.js) では CommonJs が使われることが多いようです。 CDK もプロジェクト作成時に CommonJs で作成されます。

typescriptbook.jp

今回は 1 の方法をメインで紹介し、2 の方法については簡易的に紹介します。
また、2 の方法については Experimental な機能も利用している為参考程度にお願い致します。

1. 対象の外部モジュールをトランスパイル対象にする

jest.config.js を次のように修正し、特定のモジュールをトランスパイル対象に含めるようにします。 transformIgnorePatterns については、少し分かりづらいかもしれませんが、 node_modules 配下の@middy 以外を ignore するよう正規表現を使用しています。 つまり、node_modules/@middy に一致するものはトランスパイル対象としています。

module.exports = {
  testEnvironment: "node",
  roots: ["<rootDir>/test"],
  testMatch: ["**/*.test.ts"],
  transform: {
    "^.+\\.tsx?$": "ts-jest",
  },
  transformIgnorePatterns: ["node_modules/(?!@middy)"],
};

これで、middy モジュールがトランスパイル対象となりましたが この状態で再度テストをしてみてもエラーとなるかと思います。

Jest ではデフォルトで トランスフォーマーに babel-jest が使われているのですが、 cdk の初期設定では、transform の部分で.ts ファイルに対しては ts-jest を使用するよう明示的に指定されています。 この場合、.js ファイルに対しては babel-jest を使う、というように明示的に設定する必要があるようです。

jestjs.io

ドキュメントにあるように追加で必要になるパッケージをインストールしておきます。

jestjs.io

$ npm install --save-dev babel-jest @babel/core @babel/preset-env

次に babel.config.js を作成します。

module.exports = {
  presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};

ts-jest には js-with-babel というプリセットが用意されており、 .ts には ts-jest、.js には babel-jest を使用するようなのでこれを使用することにします。

kulshekhar.github.io

module.exports = {
  preset: "ts-jest/presets/js-with-babel",
  testEnvironment: "node",
  roots: ["<rootDir>/test"],
  testMatch: ["**/*.test.ts"],
  transformIgnorePatterns: ["node_modules/(?!@middy)"],
};

preset を使用せず以下の transform でも問題ありません。

module.exports {
// ...
  transform: {
    "^.+\\.tsx?$": "ts-jest",
    "^.+\\.jsx?$": "babel-jest",
  },
};

これで、テストが通るようになり無事解決できました。

Lambda へのデプロイではどうなるのか

これまでは、開発環境でのエラー解消の為 CommonJs として統一するように対処しましたが、 Lambda へのデプロイ時は、ESModule と CommonJs が混在していて問題ないのか、 また、どちらの形式で Lambda にデプロイされるのでしょうか。

NodejsFunction を使用してデプロイする場合、デフォルトの出力フォーマットが CommonJs となっている為 cdk synth すると、CommonJs 形式のファイルが生成されます。

docs.aws.amazon.com

2. CDK アプリケーション全体を ESModule 化する

1 の方法では、TypeScript を CommonJs にトランスパイルしつつ、一部の ESModule の外部ライブラリも CommonJs にトランスパイルしていましたが、 2 の方法では、全て ESModule にトランスパイルします。

最初に出力されていたエラー情報の一部を再掲します。
一つ目に案内されているリンク (https://jestjs.io/docs/ecmascript-modules) の情報をベースに対応していきます。

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

以下の流れで各種ファイルを修正していきます。

  1. jest.config を.ts に変更
  2. .ts ファイルを ESM として扱うように、jest.config.ts に"extensionsToTreatAsEsm": [".ts"] を追記する
  3. jest.config.ts の transform に"useESM": true を追記する
  4. package.json に"type": "module"を追記する
  5. package.json の test コマンドにオプションを追加して、ESModule への変換を有効にする
  6. tsconfig にて"module": "ESNext"に変更する
  7. tsconfig にて"moduleResolution": "node"を追記する

修正後の各種ファイルは以下の通りです。

  • jest.config.ts
import { JestConfigWithTsJest } from "ts-jest";

const config: JestConfigWithTsJest = {
  verbose: true,
  testEnvironment: "node",
  roots: ["<rootDir>/test"],
  testMatch: ["**/*.test.ts"],
  transform: {
    "^.+\\.tsx?$": [
      "ts-jest",
      {
        useESM: true,
      },
    ],
  },
  transformIgnorePatterns: ["node_modules/(?!@middy)"],
  extensionsToTreatAsEsm: [".ts"],
};

export default config;
  • tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "lib": ["es2020", "dom"],
    "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,
    "typeRoots": ["./node_modules/@types"]
  },
  "exclude": ["node_modules", "cdk.out"]
}
  • package.json(変更点のみ抜粋)
{
  "type": "module",
  "scripts": {
    "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
  }
  // 省略
}

これでテストが通るはずです。 ただし、アプリケーション全体を ESModule として扱うように変更した影響で、 cdk synth, deploy が失敗するようになりました。

ESModule としてデプロイできるようにする

cdk では ts-node が使われているのですが、package.json で、"type": "module"とした影響で cdk synth しようとすると次のようなエラーが出力されます。 ts-node で解決する方法が見当たらなかった為、tsx へ変更したところ上手く動作しました。

github.com

TypeError: Unknown file extension ".ts" for /<PROJECT_PATH>/bin/jest-poc.ts
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:160:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:203:36)
    at defaultLoad (node:internal/modules/esm/load:143:22)
    at async ModuleLoader.load (node:internal/modules/esm/loader:403:7)
    at async ModuleLoader.moduleProvider (node:internal/modules/esm/loader:285:45)
    at async link (node:internal/modules/esm/module_job:78:21) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
  • tsx パッケージインストール
$ npm install --save-dev tsx
  • cdk.json 変更点のみ抜粋
-  "app": "npx ts-node --prefer-ts-exts bin/jest-poc.ts",
+  "app": "npx tsx bin/jest-poc.ts",

また、Lambda ソースコードをデプロイする際に NodeFunctions の場合はデフォルトでは CommonJs でトランスパイルされる為、 ESModule でトランスパイルするよう修正します。

  • stack 定義ファイル変更点のみ抜粋
 export class JestPocStack extends cdk.Stack {
   constructor(scope: Construct, id: string, props?: cdk.StackProps) {
     super(scope, id, props);
     new NodejsFunction(this, "MyFunction", {
       runtime: lambda.Runtime.NODEJS_22_X,
       entry: "src/handler/example1.ts",
       handler: "handler",
+      bundling: {
+        format: cdk.aws_lambda_nodejs.OutputFormat.ESM,
+      },
     });
   }

これで、cdk deploy すると ESModule 形式で Lambda にデプロイされていることが確認できるかと思います。

おわりに

CommonJs と ESModule の違いなど、あまり詳しく調べてこなかったので 今回のエラーを通じて少し理解が深まりました。

同じような境遇の方の参考になれば幸いです。

鎌田 義章 (執筆記事一覧)

2023年4月入社 アプリケーションサービス本部ディベロップメントサービス3課