LoginSignup
14
7

(2023/06)Jest における CommonJS / ECMAScript Modules の扱いについて

Posted at

1. 概要

JavaScript における CommonJS(以下、CJS)と ECMAScript Modules(以下、ESM)の問題は色々なところで発生する厄介な問題です。

Jest を使ったテストコードにおいてもこの問題に直面することがあります。

この文章は Jest における CommonJS / ECMAScript Modules の扱いについて包括的な情報提供を目指して書いています。

これを書くにあたって私は様々な情報収集をした他、不明瞭なところは実験サンプルを作って動きをみたり、関連するモジュールの中身を見てみたり、色々なことをしています。

正確な記載を心掛けますが、間違いを発見した場合は遠慮なく指摘をしてください。しかし、Node.js の世界は進歩が速いので受けた指摘をこの文章に反映するかどうかはわからないことを予め申し上げておきます。

基本的にはこの文章は執筆時点のものとして残しておくことを考えています。執筆時点においてなお間違っている内容についてはぜひともこの文章に反映したいです。

しかし、将来的に前提が大きく変わったのであれば、それは別の文章になると私は想像しています。それを書くのは私かもしれませんし、あなたかもしれません。

2. 前提

以下の環境およびバージョンを前提としています。

  • OS: Windows10 Home Edition
  • Node v18.16.0(執筆時 2023/06/18 の最新の LTS バージョン)
  • Jest v29.5.0(執筆時 2023/06/18 の最新バージョン)

3. 背景

まず大前提として Jest は CJS を前提として動くものだということ知っておく必要があります。

テストコード/テスト対象コードを ESM で書きたい場合、第一の選択肢はテストコード/テスト対象コードを ESM から CJS に動的に変換することです。これには babel-jest トランスフォーマーを使います。babel-jest は babel を使ってテストコード/テスト対象コードをテスト実行時にメモリ上で CJS にトランスパイルします。

ESM を使う場合でも多くのケースは以上で片付きます。また、TypeScript を使っている人もここに該当する可能性が高く理解が必要になるケースがあります。

上記とは別に特別なケースとしてテストコード/テスト対象コードで ESM Only のモジュールを使おうとしている場合があります。この場合、以上の対応だけでは解決できません。この ESM Only モジュールの使用が Jest における CJS / ESM の扱いについて詳しく調べるきっかけになりました。

具体的には react-markdown モジュールが ESM Only で、これを使った React コンポーネントのテストを動かすのに私は四苦八苦しました。react-markdown はバージョン 6 までは CJS にも対応していましたがバージョン 7 から ESM Only となったようです。執筆時点での react-markdown のバージョンは既に 8 です。

ESM Only モジュールの使用を Jest で単純に扱えない理由は以下です。

  • デフォルトの設定では node_modules 配下のソースは babel-jest(および babel)の変換対象にならない(ESM Only モジュールは ESM のままということ)。
  • Jest は CJS を前提としているのにも関わらず、CJS から ESM が読み込めない(Node.js の制約)。

ESM Only モジュールを動かすには上記の 2 つのうちのいずれかを解決する必要があります。

ネットを調べると react-markdown を Jest 上に持ち込むための対応方法にバリエーションが存在しますが、それはどちらの前提を崩すかの選択によるものです。解決方法は1つではありません。さらに各自の個別の状況が掛け合わさっています。

解決方法は複数ありますが、CJS / ESM の扱いという観点から ESM で Jest を動かす方法を特に解説したいと思っています。ESM で Jest を動かせば CJS から ESM が読み込めないという問題が解消します。

実験的という位置づけではありますが、現時点で Jest は ESM をサポートしています。以下は Jest の公式ページです。

これを使えば ESM で Jest を動かすことができます。上記はとても短い文章ですが、それを理解した上で各自の状況に対応するには情報が少ないと感じます。より多くの情報を提供したいと思っています。

これが私がこの文章を書こうと思った背景です。


4. 詳細

では具体的なトピックに入っていきましょう。

4.1. テストコード/テスト対象コードを ESM で書く

4.1.1. 概要

テストコード/テスト対象コードで ESM Only のモジュールを使おうとしている特殊ケース以外のほとんどの場合、ここを理解するだけで十分だと思います。

一番重要なポイントは Jest が CJS を前提として動きますが、コードとしてファイルに書いたものの形式が何であれ、Jest が実行する段階において CJS であれば良いということです。

そのための選択肢は無数にあります。あなたの状況に合った方法を採用する必要があります。例えばあなたは TypeScript を使っているかもしれません。JSX を解釈する必要もあるかもしれません。それらの変換を加味した上で Jest に CJS のコードを届けてください。

方法を選択する上で知っておきたい Jest の機能・設定について解説します。

4.1.2. トランスフォーマー

Jest にはトランスフォーマーという仕組みがあり、テスト実行時にコードをメモリ上でトランスパイルすることができます。

トランスフォーマーは jest.config.json の transform で設定します。以下は未設定時の transform のデフォルト値です。

{
  "transform": {
    "\\.[jt]sx?$": "babel-jest"
  }
}

キーは適用対象ファイルを表す正規表現です。値はトランスフォーマーの名前です。js / jsx / ts / tsx 拡張子のファイルに対して babel-jest というトランスフォーマーを適用します。

デフォルト設定で足りる場合、設定は不要です。デフォルト設定では cjs / mjs の拡張子が入らないので必要なら調整しましょう。

babel-jest は Jest に同梱されているトランスフォーマーで、.babelrc / babel.config.js に従って適用対象ファイルを babel でトランスパイルします。

デフォルトの babel-jest は babel のオプションを通じ実行環境が ESM 未サポートであることを伝えます。babel のオプションとは具体的には caller オプションです。これにより @babel/preset-env によるトランスパイルの結果が全て CJS に調整されるようです。

ただし、CJS に調整されるのは @babel/preset-env にモジュールの判断を自動で任せるなどした場合の話です。当てはまらない .babelrc の設定例を1つ紹介します。

.babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

上記の modules: false は import / export 文をそのままにするという明示的な指定です。そうした場合、以下のエラーが出ます。

SyntaxError: Cannot use import statement outside a module

.babelrc / babel.config.js は Jest によるテストだけではなくプロジェクト全体に影響するものということは覚えておく必要があります。

Jest によるテスト時だけ設定を変える必要が生じる場合もあるでしょう。その場合の書き方の例を以下に示します。必要に応じて参考にしてください。

.babelrc
{
  "presets": [
    [ "@babel/preset-env", { "module": false } ]
  ],
  "env": {
    "test": {
      "presets": [ "@babel/preset-env" ]
    }
  }
}

4.1.3. テストコード

テストコードは jest.config.xxx の testMatch または testRegex のいずれかで設定します。この 2 つは表現方法が違うだけで 1 つの設定です。設定をデフォルトから変更する場合、どちらかを選択して設定します。

以下は未設定時の testMatch と testRegex のデフォルト値です。

.jest.config.json
{
  "testMatch": [ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)" ],
  "testRegex": "/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$"
}

デフォルト設定では cjs / mjs の拡張子が対象にならないので必要に応じて調整しましょう。


4.2 ESM で Jest を動かす

4.2.1. 概要

テストコード/テスト対象コードで ESM Only のモジュールを使おうとしている特殊ケースにおいては以下の2つの制約のうちいずれかを打破する必要があります。

  • デフォルトの設定では node_modules 配下のソースは babel-jest(および babel)の変換対象にならない(ESM Only モジュールは ESM のままということ)。
  • Jest は CJS を前提としているのにも関わらず、CJS から ESM が読み込めない(Node.js の制約)。

本項では ESM で Jest を動かすことによって後者の制約を解消する方法を主に紹介します(前者の制約を解消する方法についても補足で軽く触れます)。

実験的という位置づけではありますが、現時点で Jest は ESM をサポートしています。以下は Jest の公式ページです。

ポイントは以下です。

  • Jest 実行の node コマンドに --experimental-vm-modules オプションを指定すること。
  • 拡張子による CJS / ESM の形式判断と中身を一致させること。
  • ESM Only モジュールを使うテスト対象のテストコードは ESM にすること。
  • jest オブジェクトの扱いの違いに注意すること。

一つずつ説明していきます。

4.2.2. --experimental-vm-modules オプションの使用

このオプションは Node.js の vm モジュールの実験的機能を有効にするオプションです。このオプションにより ESM ソースの動的読み込み機能が有効になります。

Jest はトランスフォーマーによりメモリ上でトランスパイルを行い、その結果を動的に読み込んで動きます。Jest はこれがないと ESM ソースの動的読み込みができずテストを実行できません。

--experimental-vm-modules は実質的に Jest の ESM 関連の各種機能を有効にします。これにより Jest は CJS と ESM の両方を区別して処理するようになります。

4.2.3. ファイルの CJS / ESM の扱いについて

Jest も一般的な Node.js と同様のルールにより中身の形式を以下の通り想定します。

拡張子 想定形式
.mjs ESM
.cjs CJS
.js package.json の type が module なら ESM、それ以外なら CJS
それ以外 jest.config.xxx の extensionsToTreatAsEsm に対象の拡張子があれば ESM、それ以外なら CJS

この判定処理は Jest による独自の判定実装です。Node.js に似せていますが extensionsToTreatAsEsm の部分などは独自のものになっています。Node.js とは独自実装をしなければならないことに関しては議論になっているようです。

想定形式は babel-jest によるトランスパイルとソースの読み込みの両方に一貫した影響を与えます。
想定形式と異なる形式でテストコード/テスト対象コードを書くと想定外のエラーに見舞われるので注意しましょう。

デフォルトの babel-jest は @babel/preset-env によるトランスパイルの結果を CJS に調整すると「4.1.2. トランスフォーマー」で述べました。
--experimental-vm-modules オプションを有効にすると想定形式を考慮した調整を行うよう Jest の挙動が変わります。拡張子による想定形式と中身を一致させれば基本的に @babel/preset-env の自動判断に任せれば良いはずです。

デフォルトの設定では .jsx / .ts / .tsx が CJS と判断されてしまうので、必要に応じて extensionsToTreatAsEsm の設定を追加しましょう。

4.2.4. ESM Only モジュールに関連するテストコードの注意点

CJS から ESM は読み込めないので ESM Only モジュールを使うテスト対象のテストコードは必然的に ESM でなければなりません。

4.2.5. jest オブジェクトの扱いについて

ESM で jest オブジェクトを使うには以下の import 文が必要になります。

import {jest} from '@jest/globals';

CJS での書き方は変わりません。

これは CJS と ESM の内部構造の違いに起因していそうです。CJS で使っていた機構が ESM で使えず、また同じ書き方を維持する他の方法が存在しないか模索中ということだと思います。

4.2.6. 事例:react-markdown を使ったコンポーネントのテスト例

私が Jest における CJS / ESM の扱いについて詳しく調べるきっかけになった react-markdown を使ったコンポーネントのテストを例に挙げます。

理解のしやすさのためにシンプルさを追求した例になります。create-next-app で全て Yes を選択した状態から開始します。

# 全て Yes
npx create-next-app

# Jest および NODE_OPTIONS=--experimental-vm-modules を指定して Jest を実行するために cross-env を入れます(for Windows)。
npm install --save-dev jest cross-env

# react-markdown を入れます。
npm install react-markdown

# React コンポーネントをテストするための各種ツールを入れます。
npm install --save-dev jest-environment-jsdom @testing-library/jest-dom @testing-library/react

package.json の scripts に test を追加します。npm run test でテストを実行します。

package.json
{
  ... 省略 ...
  "scripts": {
    ... 省略 ...
    "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest"
  },
  ... 省略 ...
}

--no-warnings は以下の出力を抑制するための追加のオプションです。

(node:4336) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

.babelrc を設定します。next/babel は Next.js のデフォルトの変換です。TypeScript / JSX のトランスパイルを babel-jest で有効にするために明示します。next/babel は TypeScript / JSX のトランスパイルを含んでいます。

.babelrc
{
  "presets": [
    "next/babel"
  ]
}

jest.config.json を設定します。extensionsToTreatAsEsm で Jest に対して .tsx 拡張子を ESM transform /
testMatch / testRegex はデフォルトで tsx を処理してくれるので設定不要です。testEnvironment / setupFilesAfterEnv は React コンポーネントをテストするための Jest の設定です。

jest.config.json
{
  "extensionsToTreatAsEsm": [
    ".tsx"
  ],
  "testEnvironment": "jsdom",
  "setupFilesAfterEnv": [
    "./jest.setup.ts"
  ]
}

jest.setup.ts は以下の通りです。

jest.setup.ts
import '@testing-library/jest-dom';

テスト対象コードです。message プロパティの値を react-markdown で表示するだけのシンプルなものです。

MarkdownComponent.tsx
import ReactMarkdown from 'react-markdown';

interface MarkdownComponentProps {
  message: string;
}

const MarkdownComponent: React.FC<MarkdownComponentProps> = ({ message }) => {
  return <ReactMarkdown children={message} />;
};

export default MarkdownComponent;

テストコードです。MarkdownComponent の message に指定したテキストの出力を確認します。

MarkdownComponent.test.tsx
import { render, screen } from "@testing-library/react";
import MarkdownComponent from "./MarkdownComponent";

describe("MarkdownComponent", () => {
  it("renders the provided message in Markdown format", () => {
    render(<MarkdownComponent message="test" />);

    expect(screen.getByText("test")).toBeInTheDocument();
  });
});

以上で上記のテストは通ります。

しかし、たったこれだけのテストが Jest の ESM の機能を有効にしないと通りません。--experimental-vm-modules オプションの指定を止めて Jest の ESM の機能を無効にすると以下のエラーが出ます。

 xxx\node_modules\react-markdown\index.js:6
    export {uriTransformer} from './lib/uri-transformer.js'
    ^^^^^^

    SyntaxError: Unexpected token 'export'

    > 1 | import ReactMarkdown from 'react-markdown';
        | ^
      2 |
      3 | interface MarkdownComponentProps {
      4 |   message: string;

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1495:14)
      at Object.require (src/components/MarkdownComponent.tsx:1:1)
      at Object.require (src/components/MarkdownComponent.test.tsx:2:1)

今回の例では ESM の機能を有効にするにあたって必要な変更は以下の2点だけです。

今回の例では jest オブジェクトを使っていませんが、実際のテストでは jest オブジェクトの扱いの違いによる差異が発生します。

4.2.7. 補足:ESM Only モジュールを CJS へトランスパイルして対応する

ここまでは ESM Only モジュールを使うために ESM を有効にしました。
別解として ESM Only モジュールをトランスフォーマーで CJS にトランスパイルしてしまうという方法を紹介します。

そのためのポイントは以下の通りです。

  • .babelrc ではなく .babel.config.js を使う。
  • jest.config.xxx の transformIgnorePatterns で node_modules 配下の特定モジュールをトランスパイルを解禁する。

まず1点目ですが .babelrc と .babel.config.js は一見して同じ機能を持っている思っていたのですが node_modules の扱いに違いがあるそうです。

設定ファイル node_modules 配下への適用有無
.babelrc しない
.babel.config.js する

.babelrc は node_modules に適用されません。node_modules 配下に適用するためには .babel.config.js にする必要があります(この記事にこれを書いているということは?そうです。私はこれに引っ掛かって時間を無駄にしたことがあります(´;ω;`))。

次に2点目ですが Jest のデフォルトの設定は node_modules 配下をトランスフォーマーの適用対象外としています。正規表現の否定先読み(?!)を使い node_moduels 配下の特定モジュールのトランスパイルを解禁します。

react-markdown を実際にこの方法で対応しようとした場合、jest.config.json の設定は以下のようになります。

jest.config.json
{
  ... 省略 ...
  "transformIgnorePatterns": [
    "/node_modules/(?!react-markdown|vfile|unist-|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-|micromark|decode-named-character-reference|remark-rehype|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|trim-lines)"
  ],
  ... 省略 ...

実際に試したところ react-markdown だけではなく関連するモジュール群も対象にする必要がありました。
テストを動かしてエラーメッセージから問題になったモジュールを確認し、それを追加して再度テストを動かして…を繰り返して設定したものになります。
react-markdown のバージョンアップによって必要なものが増えることがあります。

この方法でもテストを通すことはできます。

5. まとめ

Jest は CJS を前提として動きます。Jest は babel-jest を通じて @babel/preset-env に働きかけることで ESM を CJS にトランスパイルしてテストを動かします。

多くのケースは以上で問題なく動きます。

しかし、ESM Only モジュールを使っている場合は特別な対応が必要になります。選択肢の一つに Jest の ESM 機能を有効にするという手段があります。ただし、この機能は実験的な位置づけであるということには注意が必要です。私はまだこの機能の限界について完全には理解していません。


X. 備考:Jest を理解するために

Jest を理解するために私は以下の 2 つの手段を使いました。

  • jest に console.log を仕込んでデバッグする
  • jest の E2E テストを読んで理解する。

Jest を詳しく調べたいと思った人の助けになるよう具体的な方法についてメモを残します。

jest をデバッグする

jest の動きを理解するために細かくわからないところは jest に console.log を仕込んで動きを見る方法です。

まずは準備です。

# Jest の GitHub リポジトリをクローンする。
git clone https://github.com/facebook/jest.git

# クローンしたリポジトリに移動する。
cd jest

# 必要な依存関係をインストールする。
yarn install

ここで jest のソースコードに console.log 等を仕込みます。

該当のソース群は jest/packages 配下にあります。jest-runtime / babel-jest 周りを主に見ました。

# ビルドする。
yarn build

# インストールする。
npm install -g ./packages/jest-cli

再度デバッグの出力を仕込みたい場合は yarn build を実行します。

次にテスト側のコードです。

グローバルの jest を使うので個別の依存を外してください。

npm uninstall jest

transform の指定でデバッグコードを仕込んだ babel-jest を適用します。
以下はテストコードのディレクトリと同じ並びに jest 本体のコードがあることを想定した例です。

jest.config.json
{
  ... 省略 ...
  "transform": {
    ""\\.[jt]sx?$"": "<rootDir>/../jest/packages/babel-jest/build/index.js"
  },
  ... 省略 ...
}

最後に jest の実行に --no-cache オプションを付けます。jest はデフォルトでトランスフォームの結果をキャッシュします。トランスフォームの挙動を console.log に出力する場合、--no-cache を付けることで都度トランスパイルが動くようになります。

package.json
{
  ... 省略 ...
  "scripts": {
    ... 省略 ...
    "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --no-cache"
  },
  ... 省略 ...
}

jest の E2E テストを読む

jest を理解するにあたって jest の E2E テストを読むことが助けになるかもしれません。

jest の e2e ディレクトリ配下に E2E テストがあります。構成は以下の通りです。

  • e2e
    • __tests__: テストコード本体が入っている。
    • それ以外のディレクトリ: テストコードでテストする Jest のテストプロジェクトが入っている。

テストコード本体には以下のような記述があります。runJest の引数の文字列がテストプロジェクトのディレクトリを表しています。

asyncAndCallback.test.ts
// ... 省略 ...
test('xxx', () => {
  const result = runJest('promise-and-callback');
  // ... 省略 ...
}

ESM に関係しそうなテストには以下のようなものがあります。

  • esmConfigFile.test.ts
  • nativeEsm.test.ts
  • nativeEsmTypescript.test.ts
  • testEnvironmentEsm.ts

Jest の想定を理解するのに役立つと思います。

実際にテストを実行してみようと思ったら以下のコマンドで実行できます。

# 全てのテストを実行する(E2E テスト以外も含む)。
yarn test

# 全ての E2E テストを実行する。
yarn test-ci-partial e2e

# 特定のテストだけを実行する。
yarn test-ci-partial esmConfigFile

以上です。

14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7