JestでCannot find module が出たら依存モジュールのexportsフィールドにdefaultフォールバックを書け

## 先に結論だけ

Jestで、依存先のPure ESMモジュールを読み込んでテストしたら

```bash:Log
 FAIL  __test__/SphericalRotor.spec.ts
  ● Test suite failed to run

    Cannot find module '@masatomakino/threejs-spherical-controls' from '__test__/SphericalRotor.spec.ts'

    > 1 | import {
        | ^
      2 |   SphericalController,
      3 |   SphericalParamType,
      4 | } from "@masatomakino/threejs-spherical-controls";

      at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:427:11)
      at Object.<anonymous> (__test__/SphericalRotor.spec.ts:1:1)
```

`Resolver`でエラーが出ている場合

```bash:Log
at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:427:11)
```

**依存パッケージ**のPackage.jsonを開いて

```diff_json:package.json
{
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./esm/index.d.ts",
        "default": "./esm/index.js"
      },
+     "default": {
+       "types": "./esm/index.d.ts",
+       "default": "./esm/index.js"
+     }
    }
  }
}
```

[exportsフィールド](https://nodejs.org/api/packages.html#conditional-exports)に"default"フィールドを追加してください。

## はじめに

この記事は、Jest29におけるモジュール解決について記録、共有するためのものです。

### 想定する環境

この記事は、Jest v29.7およびts-jest v29.1を想定して書かれています。Jestのバージョンが異なる場合は、記事の内容がそのまま適用できないかもしれません。記事を読む前に、お手元の環境をご確認ください。

### 想定する読者

この記事は、すでにJestを利用しているユーザーに向けて書かれています。Jestの基本的な使い方については、[公式ドキュメント](https://jestjs.io/ja/docs/getting-started)をご参照ください。

## JestにおけるESM対応

JestにおけるESM対応は実験段階に位置付けられています。Jest29でESMをテストするには以下の[2つの方法](https://jestjs.io/ja/docs/ecmascript-modules)があります。

- `--experimental-vm-modules`オプションを指定してESMを変換せずに実行する
- transformerを利用して、ESMをCommonJSに変換して実行する

この記事では、後者の方法を紹介します。

### CommonJSにトランスパイルする

ESMをCommonJSに変換する方法は[ts-jestのドキュメント](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/)で紹介されています。

```ts:jest.config.ts
import type { JestConfigWithTsJest } from 'ts-jest'

const jestConfig: JestConfigWithTsJest = {
  extensionsToTreatAsEsm: ['.ts'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        useESM: true,
      },
    ],
  },
  transformIgnorePatterns: [`node_modules/(?!(@masatomakino/threejs-spherical-controls)/)`],
}

export default jestConfig
```

ts-jestをトランスフォーマーとして、TypeScriptやESMのJavaScriptをCommonJSに変換します。[transformIgnorePatterns](https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring)には、依存しているPure ESモジュールを指定します。`?!(モジュールのパス)`のように指定すると、指定されたモジュールは変換の対象となります。

### Jestのモジュール解決

Jestは独自の[モジュール解決処理](https://github.com/jestjs/jest/blob/main/packages/jest-resolve/src/defaultResolver.ts)を実装しています。この処理は、Package.jsonの`exports`フィールドを参照し、defaultフィールドを順に解決していくようです。

```json:package.json
{
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./esm/index.d.ts",
        "default": "./esm/index.js"
      }
    }
  }
}
```

したがって、このようにdefaultフォールバックがないexportsフィールドを持つモジュールは、解決に失敗します。

defaultフォールバックを追加すれば、この問題は解決します。

```diff_json:package.json
{
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./esm/index.d.ts",
        "default": "./esm/index.js"
      },
+     "default": {
+       "types": "./esm/index.d.ts",
+       "default": "./esm/index.js"
+     }
    }
  }
}
```

importとdefaultフォールバックをフラットな階層においても解決に成功します。

```json:package.json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./esm/index.d.ts",
      "import": "./esm/index.js",
      "default": "./esm/index.js"
    }
  }
}
```

## 個人的な感想

node.jsの周辺ツールにおける、ESMサポートはまだ成熟していません。ツール同士で処理に差があります。どのようなツールで扱われても問題が出ないように、exportsフィールドのdefaultフォールバックは念入りに書き込んでおいた方が安全です。

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