Jest の ESM/NodeNext 設定完全ガイド:transformIgnorePatterns と resolver 設計
モダンな JavaScript プロジェクトでは、ESM(ECMAScript Modules)や TypeScript の NodeNext モジュール解決が標準になりつつあります。しかし、Jest でテストを実行しようとすると「SyntaxError: Cannot use import statement outside a module」や「Cannot find module」といったエラーに直面することも多いでしょう。
本記事では、Jest で ESM と NodeNext を適切に扱うための設定方法を、transformIgnorePatterns と resolver 設計を中心に詳しく解説します。この記事を読めば、ESM 環境での Jest テストがスムーズに動作するようになります。
背景
JavaScript モジュールシステムの進化
JavaScript のモジュールシステムは、CommonJS から ESM へと移行しています。
mermaidflowchart LR
cjs["CommonJS<br/>(require)"] -->|進化| esm["ESM<br/>(import/export)"]
esm -->|TypeScript| nodenext["NodeNext<br/>モジュール解決"]
cjs -->|レガシー| bundler["Bundler 依存"]
esm -->|標準化| native["ネイティブ対応"]
この図は、JavaScript モジュールシステムの進化の流れを示しています。CommonJS から ESM へ、そして TypeScript の NodeNext へと段階的に移行が進んでいることがわかります。
モジュールシステムの特徴比較
| # | 項目 | CommonJS | ESM | NodeNext |
|---|---|---|---|---|
| 1 | 構文 | require() / module.exports | import / export | ESM + 拡張子必須 |
| 2 | ロード | 同期的 | 非同期的 | 非同期的 |
| 3 | 静的解析 | 不可 | 可能 | 可能 |
| 4 | Node.js 対応 | v0.x〜 | v12.x〜 | v12.x〜 |
| 5 | ファイル拡張子 | .js | .mjs / .js | .ts / .mts |
Jest とモジュールシステムの課題
Jest はもともと CommonJS を前提に設計されており、ESM のネイティブサポートは実験的な段階です。
Node.js が ESM をネイティブでサポートする一方で、Jest は内部的にトランスパイルを行って CommonJS に変換する仕組みを採用しています。このギャップが、設定の複雑さを生み出しているのです。
主な問題点は以下の通りです。
node_modules内の ESM パッケージが変換されない- TypeScript の NodeNext モジュール解決が Jest と一致しない
- 拡張子解決の挙動が異なる
- 動的インポートの扱いが複雑
課題
transformIgnorePatterns のデフォルト動作
Jest のデフォルト設定では、node_modules 内のファイルはトランスフォームの対象外となります。
typescript// Jest のデフォルト設定
transformIgnorePatterns: [
'/node_modules/',
'\\.pnp\\.[^\\/]+$',
];
このコードは、Jest がデフォルトで node_modules 配下のファイルを変換しないことを示しています。
この設定により、node_modules 内の ESM パッケージがそのまま実行されようとして、以下のようなエラーが発生します。
textSyntaxError: Cannot use import statement outside a module
> 1 | import { someFunction } from 'esm-package';
| ^
このエラーは、ESM の import 構文が CommonJS 環境で実行されようとしたときに発生する典型的なエラーです。
モジュール解決の不一致
TypeScript の moduleResolution: "NodeNext" と Jest のモジュール解決には、以下の相違点があります。
mermaidflowchart TB
subgraph typescript["TypeScript (NodeNext)"]
ts_import["import './file'"] -->|解決| ts_rules["1. ./file.ts<br/>2. ./file.tsx<br/>3. ./file.d.ts"]
end
subgraph jest["Jest (デフォルト)"]
jest_import["import './file'"] -->|解決| jest_rules["1. ./file<br/>2. ./file.js<br/>3. ./file.json"]
end
ts_rules -.->|不一致| error["ModuleNotFoundError"]
jest_rules -.->|不一致| error
上記の図から、TypeScript と Jest でファイル拡張子の解決順序が異なることが理解できます。
モジュール解決の違い
| # | 解決方法 | TypeScript NodeNext | Jest デフォルト |
|---|---|---|---|
| 1 | 拡張子省略 | 不可(明示必須) | 可能 |
| 2 | .ts 解決 | 優先 | 低優先度 |
| 3 | index ファイル | index.ts | index.js 優先 |
| 4 | package.json の exports | 完全サポート | 部分サポート |
| 5 | 条件付きエクスポート | サポート | 限定的 |
ESM パッケージの識別問題
どのパッケージが ESM なのかを判断するのは簡単ではありません。
json// package.json での ESM の宣言
{
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
このコードは、パッケージが ESM であることを宣言する package.json の例です。"type": "module" が重要なキーとなります。
パッケージによっては、以下のような多様な形式が存在します。
- Pure ESM(ESM のみを提供)
- Dual Package(CommonJS と ESM の両方を提供)
- Conditional Exports(条件付きエクスポート)
- Hybrid Package(混在パッケージ)
これらを個別に識別して適切に設定する必要があります。
解決策
transformIgnorePatterns の適切な設定
ESM パッケージを Jest で正しく処理するには、transformIgnorePatterns を調整する必要があります。
javascript// jest.config.js
module.exports = {
transformIgnorePatterns: [
// node_modules の中で、ESM パッケージ以外を無視
'node_modules/(?!(@testing-library|uuid|nanoid)/)',
],
};
この設定により、@testing-library、uuid、nanoid などの ESM パッケージのみがトランスフォームの対象となります。
正規表現のパターンを理解することが重要です。
javascript// パターンの解説
'node_modules/(?!package-name)/';
// ^^ ^^^^^^^^^^^^
// | トランスフォームする対象
// 否定先読み(これ以外を無視)
このコードは、否定先読み(negative lookahead)を使って、特定のパッケージだけをトランスフォーム対象にする仕組みを示しています。
transformIgnorePatterns の設定パターン
| # | パターン | 説明 | 使用場面 |
|---|---|---|---|
| 1 | node_modules/(?!pkg) | 特定パッケージのみ変換 | ESM パッケージが少数 |
| 2 | node_modules/(?!(pkg1|pkg2)) | 複数パッケージを変換 | ESM パッケージが複数 |
| 3 | node_modules/(?!@scope) | スコープ全体を変換 | monorepo 構成 |
| 4 | [] (空配列) | すべて変換 | 小規模プロジェクト |
| 5 | デフォルト | node_modules を変換しない | 非推奨 |
カスタム Resolver の実装
TypeScript の NodeNext モジュール解決に対応するには、カスタム resolver を実装します。
javascript// jest.config.js
module.exports = {
resolver: '<rootDir>/jest.resolver.js',
};
まず、Jest の設定ファイルでカスタム resolver を指定します。
次に、resolver の実装を行います。
javascript// jest.resolver.js
const { resolve } = require('path');
const fs = require('fs');
module.exports = (request, options) => {
// デフォルトの resolver を実行
try {
return options.defaultResolver(request, options);
} catch (error) {
// 拡張子を試行
const extensions = ['.ts', '.tsx', '.js', '.jsx'];
for (const ext of extensions) {
const pathWithExt = request + ext;
try {
return options.defaultResolver(
pathWithExt,
options
);
} catch {
// 次の拡張子を試行
}
}
throw error;
}
};
このコードは、拡張子が省略されたインポートに対して、複数の拡張子を順番に試行する resolver です。
さらに高度な resolver では、package.json の exports フィールドにも対応できます。
javascript// jest.resolver.js(高度な実装)
const { resolve, dirname } = require('path');
const fs = require('fs');
module.exports = (request, options) => {
// パッケージ名の場合
if (
!request.startsWith('.') &&
!request.startsWith('/')
) {
const pkgPath = resolve(
options.basedir,
'node_modules',
request,
'package.json'
);
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(
fs.readFileSync(pkgPath, 'utf8')
);
// exports フィールドの処理
if (pkg.exports) {
const exportPath = resolveExports(
pkg.exports,
options
);
if (exportPath) {
return resolve(dirname(pkgPath), exportPath);
}
}
}
}
return options.defaultResolver(request, options);
};
function resolveExports(exports, options) {
// 条件付きエクスポートの解決ロジック
if (typeof exports === 'string') {
return exports;
}
// "." エントリの解決
if (exports['.']) {
const dotExport = exports['.'];
if (typeof dotExport === 'string') {
return dotExport;
}
// require/import の条件分岐
return (
dotExport.require ||
dotExport.import ||
dotExport.default
);
}
return null;
}
このコードは、package.json の exports フィールドを解析して、適切なエントリポイントを見つける高度な resolver です。
統合設定の構築
transformIgnorePatterns と resolver を組み合わせた、完全な Jest 設定を構築します。
javascript// jest.config.js(完全版)
module.exports = {
// TypeScript のトランスパイル設定
preset: 'ts-jest',
testEnvironment: 'node',
// モジュール解決
resolver: '<rootDir>/jest.resolver.js',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
// 拡張子の解決順序
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
// ESM パッケージの変換
transformIgnorePatterns: [
'node_modules/(?!(uuid|nanoid|@testing-library|chalk|strip-ansi|ansi-regex)/)',
],
// トランスフォーム設定
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: {
moduleResolution: 'NodeNext',
module: 'NodeNext',
},
},
],
},
// ESM サポート
extensionsToTreatAsEsm: ['.ts', '.tsx'],
};
この設定は、TypeScript の NodeNext モジュール解決と ESM パッケージのトランスフォームを完全にサポートする Jest の設定例です。
統合設定のポイント
| # | 設定項目 | 役割 | 重要度 |
|---|---|---|---|
| 1 | resolver | モジュール解決のカスタマイズ | ★★★ |
| 2 | transformIgnorePatterns | ESM パッケージの変換制御 | ★★★ |
| 3 | extensionsToTreatAsEsm | ESM として扱う拡張子の指定 | ★★☆ |
| 4 | transform の useESM | ts-jest の ESM モード | ★★★ |
| 5 | moduleFileExtensions | 拡張子の解決順序 | ★☆☆ |
具体例
ケース 1:Pure ESM パッケージのテスト
Pure ESM パッケージである nanoid を使用するコードをテストする例を見てみましょう。
typescript// src/idGenerator.ts
import { nanoid } from 'nanoid';
export function generateId(): string {
return nanoid();
}
このコードは、ESM 専用パッケージの nanoid を使用して ID を生成する関数です。
テストコードを作成します。
typescript// src/idGenerator.test.ts
import { generateId } from './idGenerator';
describe('generateId', () => {
it('should generate unique ID', () => {
const id1 = generateId();
const id2 = generateId();
expect(id1).toHaveLength(21);
expect(id2).toHaveLength(21);
expect(id1).not.toBe(id2);
});
});
このテストコードは、generateId 関数が一意な ID を生成することを検証します。
Jest の設定で nanoid をトランスフォーム対象に追加します。
javascript// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transformIgnorePatterns: ['node_modules/(?!nanoid/)'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
},
extensionsToTreatAsEsm: ['.ts'],
};
この設定により、nanoid パッケージが Jest によってトランスフォームされ、テストが正常に実行されるようになります。
ケース 2:Monorepo での複数パッケージ設定
Monorepo 構成で、複数の内部パッケージと外部 ESM パッケージを扱う例です。
mermaidflowchart TB
root["ルート<br/>プロジェクト"] --> pkg1["@myapp/core<br/>(ESM)"]
root --> pkg2["@myapp/utils<br/>(ESM)"]
root --> pkg3["@myapp/ui<br/>(ESM)"]
pkg1 --> ext1["uuid<br/>(外部 ESM)"]
pkg2 --> ext2["nanoid<br/>(外部 ESM)"]
pkg3 --> ext3["@testing-library<br/>(外部 ESM)"]
style root fill:#e1f5ff
style pkg1 fill:#fff4e1
style pkg2 fill:#fff4e1
style pkg3 fill:#fff4e1
style ext1 fill:#ffe1e1
style ext2 fill:#ffe1e1
style ext3 fill:#ffe1e1
この図は、Monorepo における内部パッケージと外部 ESM パッケージの依存関係を示しています。
プロジェクト構造は以下の通りです。
textmy-monorepo/
├── packages/
│ ├── core/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── ui/
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── jest.config.base.js
├── jest.resolver.js
└── package.json
この構造は、典型的な Monorepo のディレクトリレイアウトです。
ベースとなる Jest 設定を作成します。
javascript// jest.config.base.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// 内部パッケージと外部 ESM パッケージをトランスフォーム
transformIgnorePatterns: [
'node_modules/(?!(@myapp|uuid|nanoid|@testing-library)/)',
],
// カスタム resolver
resolver: '<rootDir>/../../jest.resolver.js',
// パスマッピング
moduleNameMapper: {
'^@myapp/(.*)$': '<rootDir>/../../packages/$1/src',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: {
moduleResolution: 'NodeNext',
module: 'NodeNext',
},
},
],
},
extensionsToTreatAsEsm: ['.ts', '.tsx'],
};
このベース設定は、Monorepo 内のすべてのパッケージで共有される Jest の設定です。@myapp スコープのパッケージと外部 ESM パッケージの両方をトランスフォーム対象としています。
各パッケージでベース設定を継承します。
javascript// packages/core/jest.config.js
const base = require('../../jest.config.base');
module.exports = {
...base,
displayName: '@myapp/core',
rootDir: '.',
testMatch: ['<rootDir>/src/**/*.test.ts'],
};
このコードは、ベース設定を継承しつつ、パッケージ固有の設定を追加する方法です。
ケース 3:条件付きエクスポートへの対応
パッケージの exports フィールドで条件付きエクスポートを使用している場合の対応例です。
json// node_modules/some-package/package.json
{
"name": "some-package",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
}
}
このパッケージは、import と require で異なるファイルを提供する条件付きエクスポートを使用しています。
高度な resolver でこれに対応します。
javascript// jest.resolver.js
const { resolve, dirname, join } = require('path');
const fs = require('fs');
module.exports = (request, options) => {
// パッケージ名とサブパスを分離
const match = request.match(
/^(@?[^/]+(?:\/[^/]+)?)(.*)$/
);
if (!match) {
return options.defaultResolver(request, options);
}
const [, packageName, subpath] = match;
const pkgPath = findPackageJson(
packageName,
options.basedir
);
if (!pkgPath) {
return options.defaultResolver(request, options);
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const pkgDir = dirname(pkgPath);
// exports フィールドの処理
if (pkg.exports) {
const exportPath = resolvePackageExports(
pkg.exports,
subpath || '.',
options
);
if (exportPath) {
const fullPath = resolve(pkgDir, exportPath);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return options.defaultResolver(request, options);
};
function findPackageJson(packageName, basedir) {
try {
const modulePath = require.resolve(
`${packageName}/package.json`,
{
paths: [basedir],
}
);
return modulePath;
} catch {
return null;
}
}
function resolvePackageExports(exports, subpath, options) {
const exportEntry = exports[subpath];
if (!exportEntry) {
return null;
}
// 文字列の場合
if (typeof exportEntry === 'string') {
return exportEntry;
}
// オブジェクトの場合、条件に応じて解決
// Jest は require として動作することが多い
return (
exportEntry.require ||
exportEntry.import ||
exportEntry.default
);
}
この resolver は、package.json の exports フィールドを解析し、サブパスエクスポートと条件付きエクスポートの両方に対応します。
テストコードで条件付きエクスポートを使用します。
typescript// src/example.test.ts
import { mainFunction } from 'some-package';
import { utilFunction } from 'some-package/utils';
describe('Conditional exports', () => {
it('should import from main entry', () => {
expect(typeof mainFunction).toBe('function');
});
it('should import from subpath', () => {
expect(typeof utilFunction).toBe('function');
});
});
このテストは、メインエントリとサブパスエクスポートの両方が正しく解決されることを確認します。
ケース 4:動的インポートの処理
ESM の動的インポート(import())を Jest でテストする例です。
typescript// src/dynamicLoader.ts
export async function loadModule(moduleName: string) {
switch (moduleName) {
case 'uuid':
const { v4 } = await import('uuid');
return v4;
case 'nanoid':
const { nanoid } = await import('nanoid');
return nanoid;
default:
throw new Error(`Unknown module: ${moduleName}`);
}
}
このコードは、動的インポートを使用して実行時にモジュールを読み込む関数です。
テストコードを作成します。
typescript// src/dynamicLoader.test.ts
import { loadModule } from './dynamicLoader';
describe('Dynamic import', () => {
it('should load uuid module dynamically', async () => {
const v4 = await loadModule('uuid');
const id = v4();
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it('should load nanoid module dynamically', async () => {
const nanoid = await loadModule('nanoid');
const id = nanoid();
expect(id).toHaveLength(21);
});
it('should throw error for unknown module', async () => {
await expect(loadModule('unknown')).rejects.toThrow(
'Unknown module: unknown'
);
});
});
このテストは、動的インポートが正しく機能し、適切なモジュールを読み込むことを検証します。
Jest の設定で動的インポートをサポートします。
javascript// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transformIgnorePatterns: [
'node_modules/(?!(uuid|nanoid)/)',
],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: {
moduleResolution: 'NodeNext',
module: 'NodeNext',
},
},
],
},
extensionsToTreatAsEsm: ['.ts'],
// 動的インポートのサポート
globals: {
'ts-jest': {
useESM: true,
},
},
};
この設定により、動的インポートを含むコードが Jest で正しくテストできるようになります。
動的インポートのテストで押さえるべきポイント
- テスト関数は必ず
asyncにする awaitを使って動的インポートの結果を待つ- エラーケースも
await expect().rejectsで検証する
まとめ
Jest で ESM と NodeNext を正しく扱うための設定について解説しました。
重要なポイントの振り返り
transformIgnorePatternsを使って ESM パッケージをトランスフォーム対象に含める- カスタム resolver を実装して TypeScript の NodeNext モジュール解決に対応する
extensionsToTreatAsEsmで TypeScript ファイルを ESM として扱うuseESM: trueオプションで ts-jest の ESM モードを有効にする- Monorepo では内部パッケージもトランスフォーム対象に含める
- 条件付きエクスポートには高度な resolver で対応する
これらの設定を適切に組み合わせることで、モダンな JavaScript/TypeScript プロジェクトでも Jest を快適に使用できます。
ESM への移行は JavaScript エコシステム全体のトレンドであり、Jest もそれに追随しています。適切な設定を理解することで、テスト環境でも最新の標準を活用できるようになるでしょう。
設定が複雑に感じられるかもしれませんが、一度構築してしまえば、チーム全体で安定したテスト環境を共有できます。ぜひ本記事の内容を参考に、プロジェクトに最適な Jest 設定を構築してみてください。
関連リンク
articleJest の ESM/NodeNext 設定完全ガイド:transformIgnorePatterns と resolver 設計
articleJest の DOM 環境比較:jsdom vs happy-dom — 互換性・速度・安定性
articleJest の “Cannot use import statement outside a module” を根治する手順
articleJest の並列実行はなぜ速い?実行キューとワーカーの舞台裏を読み解く
articleJest を可観測化する:JUnit/SARIF/OpenTelemetry で CI ダッシュボードを構築
articleJest でプロパティベーステスト:fast-check で仕様を壊れにくくする設計
articleJotai でフォームを分割統治:フィールド粒度の atom 設計と検証戦略
articleElectron アーキテクチャ超図解:Main/Renderer/Preload の役割とデータフロー
articleJest の ESM/NodeNext 設定完全ガイド:transformIgnorePatterns と resolver 設計
articleDocker イメージ署名と検証:cosign でサプライチェーンを防衛する運用手順
articleGitHub Copilot Enterprise 初期構築:SSO/SCIM・ポリシー・配布ロールアウト設計
articleDevin が強い開発フェーズはどこ?要件定義~運用までの適合マップ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来