T-CREATOR

Jest の ESM/NodeNext 設定完全ガイド:transformIgnorePatterns と resolver 設計

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 へと段階的に移行が進んでいることがわかります。

モジュールシステムの特徴比較

#項目CommonJSESMNodeNext
1構文require() / module.exportsimport / exportESM + 拡張子必須
2ロード同期的非同期的非同期的
3静的解析不可可能可能
4Node.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 NodeNextJest デフォルト
1拡張子省略不可(明示必須)可能
2.ts 解決優先低優先度
3index ファイルindex.tsindex.js 優先
4package.jsonexports完全サポート部分サポート
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-libraryuuidnanoid などの ESM パッケージのみがトランスフォームの対象となります。

正規表現のパターンを理解することが重要です。

javascript// パターンの解説
'node_modules/(?!package-name)/';
//           ^^  ^^^^^^^^^^^^
//           |   トランスフォームする対象
//           否定先読み(これ以外を無視)

このコードは、否定先読み(negative lookahead)を使って、特定のパッケージだけをトランスフォーム対象にする仕組みを示しています。

transformIgnorePatterns の設定パターン

#パターン説明使用場面
1node_modules​/​(?!pkg)特定パッケージのみ変換ESM パッケージが少数
2node_modules​/​(?!(pkg1|pkg2))複数パッケージを変換ESM パッケージが複数
3node_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.jsonexports フィールドにも対応できます。

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.jsonexports フィールドを解析して、適切なエントリポイントを見つける高度な 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 の設定例です。

統合設定のポイント

#設定項目役割重要度
1resolverモジュール解決のカスタマイズ★★★
2transformIgnorePatternsESM パッケージの変換制御★★★
3extensionsToTreatAsEsmESM として扱う拡張子の指定★★☆
4transformuseESMts-jest の ESM モード★★★
5moduleFileExtensions拡張子の解決順序★☆☆

具体例

ケース 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"
    }
  }
}

このパッケージは、importrequire で異なるファイルを提供する条件付きエクスポートを使用しています。

高度な 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.jsonexports フィールドを解析し、サブパスエクスポートと条件付きエクスポートの両方に対応します。

テストコードで条件付きエクスポートを使用します。

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 設定を構築してみてください。

関連リンク