ts-jestでSyntaxError: Cannot use import statement outside a moduleが出た時の対処法

## 先に結論だけ

例として、このTypeScriptファイルのテスト実行時に

_▼問題となるimport_
```ts
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
```

以下のシンタックスエラーが発生しているなら

```
SyntaxError: Cannot use import statement outside a module
```

jest.config.jsで`transformIgnorePatterns`と`transform`フィールドを追加して

_▼jest.config.js_
```js
module.exports = {
  preset: "ts-jest",
  transformIgnorePatterns: ["/node_modules/(?!three/examples/)"],
  transform: {
    "node_modules/three/examples/.+.(j|t)sx?$": "ts-jest",
  },
  testEnvironment: "node", // or jest-environment-jsdom
};
```

tsconfig.jsonに`allowJs : true`を追加します。rootDirは指定してはいけません。

_▼tsconfig.json_
```diff_json
{
  "compilerOptions": {
-    "rootDir": "./src",
+    "allowJs": true,
   }
}
```

## はじめに

この記事は、Jestの実行時に発生する変換エラーについての情報を解説、共有するためのものです。

### 対象とする環境

この記事では、以下の環境を前提としています。

```bash
% sw_vers
ProductName:    macOS
ProductVersion: 12.6.1
BuildVersion:   21G217
% node --version
v18.12.1
% npm --version
8.19.2
```

_▼package.json_

```json
  "devDependencies": {
    "jest": "^29.3.1",
    "three": "^0.148.0",
    "ts-jest": "^29.0.3"
  }
```

:::note warn
対象となるパッケージのメジャーバージョンが変わると、記事の内容がそのまま適用できないかもしれません。記事を読む前に、お手元の環境をご確認ください。
:::

### 対象とする読者

- Jestを使ったことがある
- Jestを使っているプロジェクトが、パッケージの更新でテストに失敗した

Jestを使っているユーザーを対象としているため、この記事ではツール自体の解説やインストールガイドは扱いません。ご承知ください。

## 問題

Jestでテストしているプロジェクトのパッケージを更新すると、以下のエラーが発生してテストが失敗しました。

```
SyntaxError: Cannot use import statement outside a module
```

<details>
<summary>ログの抜粋</summary>

```
  ● 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>

package.jsonを巻き戻すとテストが成功するため、パッケージ更新がエラーの原因のようです。

https://github.com/mrdoob/three.js/pull/21801

three.jsのCHANGELOGを読んでいくと、importしているクラスがES6に移行していました。このES6への移行と、Jestの設定の不整合が問題の原因でした。

## 原因

この問題はテストにimportされるファイルが、CommonJS形式に変換されないため起きます。どのように問題が発生するのか、順を追って解説します。

### Jestとトランスフォーマー

https://jestjs.io/ja/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object

Jestはテスト時にnode.jsでサポートされていない言語をJavaScriptに変換します。対象となる言語はJSX、TypeScript、Vueテンプレートなどです。

この変換処理を担当するのがトランスフォーマーです。Jestは[デフォルト](https://jestjs.io/ja/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object)で[babel-jest](https://www.npmjs.com/package/babel-jest)を使用します。ts-jestはTypeScriptトランスフォーマーです。

### Jestは`node_modules`以下のファイルを変換しない

https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring

Jestの標準設定では`node_modules`と`.pnp`以下のモジュールを変換せず、配布された形式のまま実行します。

### `node_modules`にES Moduleのみ対応のパッケージがある場合

`node_modules`にトランスパイルされていないコードが含まれている場合、Jestはコードを理解できずに構文エラーが発生します。

https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring

> Sometimes it happens (especially in React Native or TypeScript projects) that 3rd party modules are published as untranspiled code. Since all files inside node_modules are not transformed by default, Jest will not understand the code in these modules, resulting in syntax errors. To overcome this, you may use transformIgnorePatterns to allow transpiling such modules. You'll find a good example of this use case in React Native Guide.
> 時々、サードパーティモジュールがトランスパイルされていないコードとして公開されていることがあります（特にReact NativeやTypeScriptプロジェクトにおいて）。node_modules 内のすべてのファイルはデフォルトでトランスフォームされないため、Jestはこれらのモジュール内のコードを理解できず、結果として構文エラーが発生します。これを克服するために、transformIgnorePatternsを使用して、このようなモジュールのトランスパイルを許可できます。React Native Guideに、この使用例の良い例があります。

この問題は頻繁に発生するようで、公式ドキュメントにも記載がありました。

## 対処

この問題は`node_modules`以下のファイルが変換されないため発生します。解決のため、Jestに問題となるファイルを変換するように指示します。

### jest.config.js

`jest.config.js`に2つのフィールドを追加します。

- `transformIgnorePatterns`フィールド
- `transform`フィールド

#### transformIgnorePatterns

[transformIgnorePatternsフィールドのデフォルト値](https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring
)は以下の通りです。

```js
Default: ["/node_modules/", "\\.pnp\\.[^\\\/]+$"]
```

これはnpmの`node_modules`かyarnの`pnp`ディレクトリを **すべて変換しない** という指定です。`transformIgnorePatterns`フィールドを追加し、値を以下のように書き換えます。

_▼jest.config.js_
```js
module.exports = {
  transformIgnorePatterns: ["/node_modules/(?!three/examples/)"],
};
```

この正規表現パターンは

- `node_modules`ディレクトリ以下は変換しない
- しかし`node_modules/three/examples`ディレクトリは **除外から除外する** → 変換する

という意味になります。

`?!`は[否定先読みアサーション](https://www.tohoho-web.com/ex/regexp.html#lookaround_assertion)という正規表現です。 **条件に当てはまる場合はマッチしない** という否定表現です。

二重の否定になっているので直感的に理解しにくいですが、この否定表現で`node_modules/three/examples`ディレクトリを変換対象に指定します。

#### transform

つぎに`node_modules/three/examples`ディレクトリを変換するトランスフォーマーを指定します。

_▼jest.config.js_

```js
module.exports = {
  preset: "ts-jest",
  transform: {
    "node_modules/three/examples/.+.(j|t)sx?$": "ts-jest",
  },
};
```

jest.config.jsに[`transform`フィールド](https://jestjs.io/ja/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object)を追加します。フィールドはkey-valueが対となるオブジェクトで、それぞれ

- key : 変換対象ファイルの正規表現
- value : トランスフォーマーの名前

を指定します。今回はts-jestで`node_modules/three/examples`以下のファイルを変換します。

### tsconfig.js

ts-jestは`tsconfig.json`の設定に従ってファイルを変換します。JavaScriptファイルを変換できるよう、tsconfig.jsに[`allowJs`フィールド](https://www.typescriptlang.org/ja/tsconfig#allowJs)を追加します。また、rootDirは指定してはいけません。node_modulesがコンパイル対象外になるためです。

_▼tsconfig.json_
```diff_json
{
  "compilerOptions": {
-    "rootDir": "./src",
+    "allowJs": true,
   }
}
```

これで`node_modules/three/examples`以下のファイルがTypeScriptのトランスパイル設定にしたがって変換され、テストが正常に稼働します。


## 今後の予定

Jestは、現在ES modulesへの[実験的サポートを始めています](https://jestjs.io/ja/docs/ecmascript-modules)。

Jest 30以降では、トランスフォーマーを使用せずES modulesをimportしたテストが実行できるようになるかもしれません。Jestのメジャーバージョンが更新されたら、ドキュメントを再読してください。

以上、ありがとうございました。

## 参考記事

https://stackoverflow.com/a/61785012

https://qiita.com/konyaru/items/b22916308ee6f03272db