ts-jestを使ってTypeScriptで書いたテストケースの中で、CommonJSからESModuleをimportしていたらts-jest@29.2.4からエラーでテストが通らなくなりました。
     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で確認してみたところ
- 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として投稿してみました。
が、
The support for Node16/NodeNext will be in the next major release.
と次のメジャーバージョンで対応するよ、といわれてしまいました。トホホ…
ESModuleとしてのテストケース
ちょっと本来したかったこととからは外れますが、テストケース自体をESModuleにしてしまったらどうなるのかと試してみました。
まずjest.config.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するテストケースを作成します。
describe('esmodule-import', () => {
    test('import', async () => {
        const esm = await import('esm');
        expect(esm).toHaveProperty('default');
    })
});
そしてテストを実行すると…お、通りました。勝ったッ!第3部完!
…とは行きませんでした。
テストケースにはモック関数を使うことが多々あります。モック関数の作成に使用される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:
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = function wrappedImport(packageName) {
    return import(packageName);
};
wrappedImport.d.ts:
export default function wrappedImport<T>(packageName: string): Promise<T>;
テストケースでimportしているところをこの関数に置き換えます。たとえば
const { default: original } = await import('p-limit');
と書かれているところを
const { default: original } = await wrappedImport<typeof import('p-limit')>('p-limit');
のように。
ただ、VSCodeのように、エディタ上でコンパイルエラーを検出してくれるIDEを使っているとimport('p-limit')の部分でエラーが指摘されます。
実際には何故かts-jestで実行すればエラーにはならないので、気にしないで済むなら気にしなくてもいいのですが、エディタ上でエラーが指摘されているのを放置するのが気持ち悪いという方は
// @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を使うと警告が出て困る、という場合は
// 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に世界が統一されればいいのに…
でもその頃には別のモジュール形式が登場して、また混乱しているような気がする。