CommonJSからESModuleをimportするテストでエラー

ts-jestを使ってTypeScriptで書いたテストケースの中で、CommonJSからESModuleをimportしていたらts-jest@29.2.4からエラーでテストが通らなくなりました。

```text
     Must use import to load ES Module: /home/runner/work/p-limit/p-limit/node_modules/p-limit/index.js

      167 |     });
      168 |     test('performance', async () => {
    > 169 |         const { default: original } = await import('p-limit');
          |                                       ^
 
```

CHANGELOGで確認してみたところ

https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md#2924-2024-08-01

> - fix: revert support implementation for Node16/NodeNext (70b9530), closes #4468 #4473

とのことで`Node16/NodeNext`のサポートを止めたってことなんですかね?

※でも`support Node16/NodeNext`と書いてある29.2.1より前の29.2.0やなんなら29.0.0まで巻き戻してもこのテストでエラーになってないんですが…~~てかパッチバージョン上げるだけでこういう破壊的変更は止めてくれ~~

仕方ないので、どうにかできないか方法を探ってみました。

## Issueを立ててみる

まず餅は餅屋ということで、ts-jestのIssueとして投稿してみました。

- [https://github.com/kulshekhar/ts-jest/issues/4497](https://github.com/kulshekhar/ts-jest/issues/4497)

が、

> The support for Node16/NodeNext will be in the next major release.

と次のメジャーバージョンで対応するよ、といわれてしまいました。トホホ…

## ESModuleとしてのテストケース

ちょっと本来したかったこととからは外れますが、テストケース自体をESModuleにしてしまったらどうなるのかと試してみました。

まず`jest.config.ts`を以下のように書き換えます。

```ts
import type { Config } from 'jest';

export default {
    testMatch: ['**/*.test?(m)ts'],
    moduleFileExtensions: ['ts', 'mts', 'js'],
    extensionsToTreatAsEsm: ['.mts'],
    transform: {
        '\\.m?ts$': ['ts-jest', {useESM: true}],
    },
} satisfies Config;
```

`testMatch`と`moduleFileExtensions`の設定で`mts`をテスト対象とし、`extensionsToTreatAsEsm`と`transform`でESModuleとして扱うようにしています。

元々の設定があるようなら適宜追加してください。

それから`esmodule.test.mts`にESModuleをimportするテストケースを作成します。

```ts
describe('esmodule-import', () => {
    test('import', async () => {
        const esm = await import('esm');
        expect(esm).toHaveProperty('default');
    })
});
```

そしてテストを実行すると…お、通りました。[勝ったッ!第3部完!](https://dic.pixiv.net/a/%E5%8B%9D%E3%81%A3%E3%81%9F%E3%83%83%21%E7%AC%AC3%E9%83%A8%E5%AE%8C%21)

…とは行きませんでした。

テストケースにはモック関数を使うことが多々あります。モック関数の作成に使用されるjest名前空間、これ実はグローバルには存在していないんですね。どうやっているのかは省きますが(~~実は私もよく分かってない~~)テストケース実行時に引数として渡されているようなもの、とでも考えておいてください。

ESModuleとしてテストケースを指定してしまうと、テストケース実行の流れが変わってしまうようでjestにアクセスできませんでした。

## 部分的に手動トランスパイル

最初に立てたIssueで以下のようなコメントがありました。

```
I just spent the morning building a demo repo showing the difficulty in configuring 
ts-jest to support Node16/NodeNext modules. https://github.com/jmannau/ts-jest-node16
```
> Node16/NodeNextモジュールをサポートするためにts-jestを設定することの難しさを示すデモ・レポを、午前中に作成したところだ。https://github.com/jmannau/ts-jest-node16

書かれていたリポジトリを見るといろいろなパターンでCommonJSからESModuleをimportする方法を考えてくれてました。

その中に

```
However, if running the tests on the output files emitted by typescript, the same jest tests work.
```

> しかし、typescriptが出力したファイルに対してテストを実行すると、同じjestテストが機能する。

と書かれていたのを見てひらめきました。

**import関数を呼び出すとこだけJavaScriptにトランスパイルしておけばいいんじゃね?**

というわけで`wrappedImport`関数をjsファイルとd.tsファイルに用意します。

`wrappedImport.js`:

```js
Object.defineProperty(exports, '__esModule', { value: true });

exports.default = function wrappedImport(packageName) {
    return import(packageName);
};
```

`wrappedImport.d.ts`:

```ts
export default function wrappedImport<T>(packageName: string): Promise<T>;
```

テストケースでimportしているところをこの関数に置き換えます。たとえば

```ts
const { default: original } = await import('p-limit');
```

と書かれているところを

```ts
const { default: original } = await wrappedImport<typeof import('p-limit')>('p-limit');
```

のように。

ただ、VSCodeのように、エディタ上でコンパイルエラーを検出してくれるIDEを使っていると`import('p-limit')`の部分でエラーが指摘されます。

実際には何故かts-jestで実行すればエラーにはならないので、気にしないで済むなら気にしなくてもいいのですが、エディタ上でエラーが指摘されているのを放置するのが気持ち悪いという方は

```ts
// @ts-ignore 型をimportしているだけなのでESModuleのimportでも問題ない
const { default: original } = await wrappedImport<typeof import('p-limit')>('p-limit');
```

と書けばいいです。`@ts-expect-error`ではなく`@ts-ignore`なのは前述のとおり、ts-jestで実行すればエラーにはならないので、`@ts-expect-error`では逆にエラーとなってしまうからです。

eslintを通していて`@ts-ignore`を使うと警告が出て困る、という場合は

```ts
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ts-jestで実行時にはエラーにならないので@ts-ignoreを使う
// @ts-ignore 型をimportしているだけなのでESModuleのimportでも問題ない
const { default: original } = await wrappedImport<typeof import('p-limit')>('p-limit');
```

としてください。

`@ts-ignore`や`eslint-disable`を使うなんてもってのほか、という向きには…

もうts-jestが次のメジャーバージョンアップするのをお待ちいただくしかないんじゃないでしょうか。

## まとめ

さっさとESModuleに世界が統一されればいいのに…

でもその頃には別のモジュール形式が登場して、また混乱しているような気がする。