1. 概要・背景
移行の動機
Node.js エコシステム全体が ESM (ECMAScript Modules) へ移行している。本プロジェクトで移行を実施した主な理由は以下の通り。
- Prisma v6+ が ESM ネイティブに対応し、
moduleFormat = "esm"設定が利用可能になった - Jest v30 で ESM サポートが改善され、
jest.unstable_mockModuleが安定化した "type": "module"を設定することでパッケージの意図が明確になり、CJS/ESM の混在による予期しない挙動を排除できるmoduleResolution: "bundler"により、相対インポートで拡張子を書かなくてよくなり、TypeScript の記述がシンプルになる
対象アーキテクチャ
- モノレポ管理: Turborepo + pnpm workspaces
- フロントエンド: Next.js 15 (
apps/myapp) - バックエンド/共通: 複数の TypeScript パッケージ (
packages/auth,packages/myapp-query,packages/another-query, etc.) - ORM: Prisma (
packages/myapp-prisma,packages/another-prisma) - テスト: Jest + ts-jest +
@quramy/jest-prisma-node
2. 変更ファイル一覧(チェックリスト形式)
TypeScript 設定 (packages/typescript-config/)
-
base.json"module": "nodenext"→"esnext""moduleResolution": "nodenext"→"bundler"
-
nextjs.jsonmodule/moduleResolutionの重複記述を削除(base.jsonから継承されるため不要)
各パッケージ package.json
-
"type": "module"を追加(全パッケージ・全アプリ) -
unitスクリプトを以下に変更:"unit": "NODE_OPTIONS='--experimental-vm-modules' jest" - Jest 関連パッケージのバージョンアップ:
jest:^29→^30@types/jest:^29→^30@jest/globals: 新規追加^30ts-jest: 最新版に更新
-
@quramy/jest-prisma/@quramy/jest-prisma-node:^1.7→^1.8.2 -
@quramy/prisma-fabbrica:^2.0→^2.3 -
@prisma/client/prisma:^6.3→^6.16 - Prisma パッケージのみ:
ts-node依存を削除、seed スクリプトをtsxに変更:"seed": "tsx prisma/seed.ts" - Next.js アプリのみ:
@prisma/nextjs-monorepo-workaround-pluginを削除
tsconfig.json / tsconfig.build.json (各パッケージ)
-
tsconfig.json:"types": ["@types/jest"]を削除(理由はセクション 4「@types/jestと@jest/globalsの型競合」を参照) -
tsconfig.build.json:"types": ["node"]を追加tsconfig.jsonではtypesを指定しないことで全@types/*を自動読み込みしている(@types/jestを含む)。ビルド成果物に jest の型が混入しないよう、tsconfig.build.jsonでは@types/nodeのみに制限する。"exclude": ["src/**/*.test.ts"]を追加"types": ["node"]でビルド時に@types/jestを除外すると、テストファイル内で使われるdescribe/jest等の型が解決できずエラーになる。テストファイル自体をビルド対象から外すことで回避する。
Jest 設定 (jest.config.ts)
-
extensionsToTreatAsEsm: [".ts"]を追加Jest のモジュールローダーは
.jsファイルのみpackage.jsonの"type": "module"を参照して ESM/CJS を判定する。.tsのような非.js拡張子はこの判定対象外で、extensionsToTreatAsEsmに含まれていなければ CJS として扱われる。ts-jest のuseESM: trueは ESM 構文を出力するため、Jest 側でも.tsを ESM として実行するよう明示が必要になる。 - transform 設定を
useESM: trueに変更:transform: { "^.+\\.ts$": ["ts-jest", { useESM: true }], } -
moduleNameMapperでサブパスインポートをマッピング:moduleNameMapper: { "^#domain/(.*)$": "<rootDir>/src/domain/$1", "^#processor/(.*)$": "<rootDir>/src/processor/$1", "^#interface-adapter/(.*)$": "<rootDir>/src/interface-adapter/$1", }なぜ必要か: CJS モードでは
require("#domain")の解決を Node.js が担い、package.jsonの"imports"フィールドをネイティブサポートしているため設定不要だった。ESM モード(--experimental-vm-modules)では Jest 独自のjest-resolveがモジュール解決を担うが、jest-resolveは"imports"フィールドを実装していない。そのためimport "#domain"が解決できずエラーになる。moduleNameMapperはjest-resolveによる探索より前に評価されるため、"imports"フィールドを使わずに直接マッピングして回避する。
.jest/setupAfterEnv.ts
-
インポート元を変更:
// Before import { PrismaClient } from "@myapp/myapp-prisma/client"; import { initialize } from "@quramy/jest-prisma-node"; // After import { initialize } from "@quramy/jest-prisma-node"; import { PrismaClient } from "@myapp/myapp-prisma/prisma-client";initializeをPrismaClientより前にインポートする順序が重要
Prisma スキーマ (prisma/schema.prisma)
-
generator の
providerを変更:// Before provider = "prisma-client-js" // After provider = "prisma-client" -
prisma-clientジェネレーターにmoduleFormat = "esm"を追加:generator client { provider = "prisma-client" output = "../.prisma" moduleFormat = "esm" } -
prisma-fabbricaジェネレーターにもmoduleFormat = "esm"を追加:generator fabbrica { provider = "prisma-fabbrica" output = "../src/__generated__/fabbrica" moduleFormat = "esm" }
Prisma パッケージの package.json exports 設定
-
"./prisma-client"エクスポートを追加:"exports": { "./client": "./src/client.ts", "./prisma-client": "./.prisma/client.ts", "./factories": "./src/factories.ts" }各エントリの責務は明確に分離されている:
./client→prismaシングルトンのみを export (src/client.ts)./prisma-client→PrismaClientクラス・Prisma名前空間のみを export (.prisma/client.tsを直接マップ)
なぜ分割するか:
jest.unstable_mockModuleはモジュール全体を置き換えるため、clientモジュールにPrismaClientも含めていると、モック後にPrismaClientが消えてしまう(詳細は セクション 4「jest.unstable_mockModuleはモジュール全体を置き換える」 を参照)。prisma-clientを別エントリとして独立させることで、clientをモックしてもPrismaClientは影響を受けない。
インポートパスの変更(Prisma パッケージ内)
-
src/client.ts:// Before export { PrismaClient, Prisma } from "../.prisma"; // After export { PrismaClient, Prisma } from "../.prisma/client";
Next.js 設定 (next.config.mjs)
-
PrismaPluginのインポートと使用を削除
3. テストコードの変更パターン
jest.mock → jest.unstable_mockModule
ESM ではモジュールが静的に評価されるため、CJS の jest.mock は使えない。
// Before (CJS)
jest.mock("@myapp/myapp-prisma/client", () => ({ prisma: jestPrisma.client }));
// After (ESM)
jest.unstable_mockModule("@myapp/myapp-prisma/client", () => {
return { prisma: jestPrisma.client };
});動的インポート (await import())
jest.unstable_mockModule を呼んだ後、テスト対象モジュールを await import() で読み込む。
jest.unstable_mockModule は await import() の前に呼ぶこと。
// jest.unstable_mockModule を呼んだ後
const { prisma } = await import("@myapp/myapp-prisma/client");
const { SomeClass } = await import("./some-module");モックしたモジュールを推移的に import しているクラス(SUT)も
await import()で読み込む必要がある。
Jest グローバルの明示的インポート
ESM では jest.unstable_mockModule などの ESM 専用 API を使うために、jest オブジェクトを @jest/globals から明示的にインポートする必要がある。
import { jest } from "@jest/globals";この import を追加すると
@types/jestが提供するグローバルのjest型と競合するため、tsconfig.jsonのtypesから"@types/jest"を削除する(詳細はセクション 4「@types/jestと@jest/globalsの型競合」を参照)。
jest.fn() の型注釈
// Before
const mockFn = jest.fn();
// After(型を明示して型安全にする)
const mockFn = jest.fn<SomeInterface["methodName"]>();4. 落とし穴と注意点
jest.unstable_mockModule の呼び出し順序
jest.unstable_mockModule は必ず await import() の前に呼ばなければならない。
CJS の jest.mock はファイル先頭に自動的にホイスティングされるが、jest.unstable_mockModule はホイスティングされない。
// NG: import の後でモックを設定しても効かない
const { SomeClass } = await import("./some-module");
jest.unstable_mockModule("./dependency", () => ({ ... })); // 遅すぎる
// OK: モックを先に設定してから import する
jest.unstable_mockModule("./dependency", () => ({ ... }));
const { SomeClass } = await import("./some-module");jest.unstable_mockModule はモジュール全体を置き換える
jest.unstable_mockModule ではファクトリが返すオブジェクトがそのモジュールの全エクスポートになる。部分モックはできない:
jest.unstable_mockModule("@myapp/myapp-prisma/client", () => {
// { prisma: ... } だけ返すと、このモジュールから PrismaClient は消える
return { prisma: jestPrisma.client };
});CJS の jest.mock でも同様に全体置き換えだが、次の理由で問題が顕在化しなかった:
- 実行順序:
setupFilesAfterEnv(setupAfterEnv.ts)はテストファイルより先に実行される。jest.mockはテストファイル内でホイスティングされるが、それはあくまで「テストファイル内の import より前」であり、setupAfterEnv.tsの実行より前にはならない。そのためsetupAfterEnv.tsは実際のモックが登録される前にPrismaClientの実体を取得できていた。 - CJS の値スナップショット: CJS の
requireは値をその場でバインドする。setupAfterEnv.tsがPrismaClientを取得した後でモジュールが置き換えられても、すでにバインドされた変数には影響しない。 - 型のみの使用:
PrismaClientManagerはPrismaClient/Prismaを TypeScript の型注釈としてのみ使用しており、ランタイム値としては使用していなかった。そのためモックでundefinedになっても実行時エラーが発生しなかった。
ESM では上記の保護がすべて失われる:
jest.unstable_mockModuleはawait import()と組み合わせて使うため、setupAfterEnv.tsの静的 import より後にモックが効く保証がないPrismaClientを./clientエントリに残したままだと、ESM の動作によってはsetupAfterEnv.tsでもundefinedになりうる
解決策: エクスポートエントリポイントを責務で分離する(セクション 2「Prisma パッケージの exports 設定」 を参照):
- テストでモックするエントリ (
./client) にはモック対象の export のみ入れる PrismaClientなど型・クラスは独立したエントリ (./prisma-client) から提供するsetupAfterEnv.tsは./prisma-clientから import するため、./clientのモックに影響されない
@prisma/nextjs-monorepo-workaround-plugin は不要に
ESM 移行後、@prisma/nextjs-monorepo-workaround-plugin は不要になる。next.config.mjs から削除し、依存関係からも削除する。
@types/jest と @jest/globals の型競合
ESM 専用 API(jest.unstable_mockModule 等)を使うために @jest/globals から jest を import すると、@types/jest がグローバルスコープに注入している jest 型と二重定義になり TypeScript エラーが発生する。
Duplicate identifier 'jest'. ts(2300)
解決策: tsconfig.json の types から "@types/jest" を削除する。
// Before
{ "compilerOptions": { "types": ["@types/jest"] } }
// After(types フィールド自体を削除)
{ "compilerOptions": {} }これにより @types/jest によるグローバル ambient 宣言が抑制され、@jest/globals からの import が唯一の型ソースになる。
describe/it/expect/beforeEachなどのテストグローバルは@types/jestがアンビエント宣言している。typesを削除しても@types/jestパッケージがnode_modulesに存在する限り TypeScript は自動的に読み込むため(types未指定時は全@types/*が対象)、これらのグローバル型は引き続き使用できる。
initialize のインポート順序 (jest-prisma)
.jest/setupAfterEnv.ts で @quramy/jest-prisma-node の initialize を PrismaClient より前にインポートする必要がある。ESM では import の副作用の実行順序が import 文の記述順に依存するため、この順序が重要になる。
// OK: initialize が先
import { initialize } from "@quramy/jest-prisma-node";
import { PrismaClient } from "@myapp/myapp-prisma/prisma-client";
// NG: PrismaClient が先だと jest-prisma の初期化が間に合わない
import { PrismaClient } from "@myapp/myapp-prisma/prisma-client";
import { initialize } from "@quramy/jest-prisma-node";5. 移行手順(ステップバイステップ)
-
依存パッケージのアップデート
- 全パッケージの
package.jsonで Jest 関連・Prisma 関連のバージョンを更新する pnpm installを実行してロックファイルを更新する
- 全パッケージの
-
TypeScript 設定の変更
packages/typescript-config/base.jsonのmodule/moduleResolutionを変更するpackages/typescript-config/nextjs.jsonから重複項目を削除する
-
全パッケージに
"type": "module"を追加- 各
package.jsonに"type": "module"を追加する
- 各
-
各パッケージの
tsconfig.json/tsconfig.build.jsonを更新tsconfig.jsonから"types": ["@types/jest"]を削除するtsconfig.build.jsonに"types": ["node"]と"exclude"を追加する
-
Prisma スキーマを更新してクライアントを再生成
prisma/schema.prismaのproviderとmoduleFormatを更新するprisma generateを実行して ESM 形式のクライアントを生成する
-
Prisma パッケージの
exportsとインポートパスを更新package.jsonに"./prisma-client"エクスポートを追加するsrc/client.tsのインポートパスを"../.prisma/client"に変更する
-
Jest 設定を更新
- 各パッケージの
jest.config.tsを ESM 対応に更新する(extensionsToTreatAsEsm,useESM: true,moduleNameMapper) unitスクリプトを--experimental-vm-modules付きに変更する
- 各パッケージの
-
テストコードを ESM スタイルに書き換え
jest.mock→jest.unstable_mockModuleに変更する- モジュール読み込みを
await import()に変更する import { jest } from "@jest/globals"を追加する
-
.jest/setupAfterEnv.tsを更新- インポート元とインポート順序を修正する
-
Next.js 設定を更新
next.config.mjsからPrismaPluginを削除する
-
動作確認
pnpm -r testでテストが全パス することを確認するpnpm build(Next.js)が成功することを確認する- VRT スナップショットを更新する(
pnpm test:vrtなど)