Open18

外部パッケージに ESM が使われているコードを ts-jest でテストしたい

とりあえず、そのまま実行してみる。

$ yarn test src/components/post
yarn run v1.22.10
$ jest src/components/post
 FAIL  src/components/post/Post/Post.test.tsx
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    ~/blog/node_modules/react-markdown/index.js:6
    import {ReactMarkdown} from './lib/react-markdown.js'
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

       5 | import { PrismAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
       6 | import { css } from '@emotion/react';
    >  7 | import ReactMarkdown, { Options } from 'react-markdown';
         | ^
       8 | import gfm from 'remark-gfm';
       9 | import rehypeRaw from 'rehype-raw';
      10 | import { Post as PostEntity } from '../../../entities/Post';

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1479:14)
      at Object.<anonymous> (src/components/post/Post/Post.tsx:7:1)

SyntaxError: Cannot use import statement outside a module とシンタックスエラーが発生。

react-markdown が v7 から ESM(ECMAScript Module) で提供されるようになったが、jest は Node.js 環境で実行されるので、何も設定していないと ESM は読み込めずシンタックスエラーとなる。

CommonJS: module.exports/require
ESM: export/import

Jest では ESM をサポートするために --experimental-vm-modules を Node.js の実行オプションとして指定する必要がある。

https://jestjs.io/ja/docs/ecmascript-modules
{
  "scripts": {
    "test": "NODE_OPTIONS=--experimental-vm-modules jest"
  }
 }

Node.js で ESM を有効にするために、pakcage.json に "type": "module" を追加

{
  "type": "module"
}

実行してみる。

$ yarn test src/components/post
yarn run v1.22.10
$ NODE_OPTIONS=--experimental-vm-modules jest src/components/post
ReferenceError: module is not defined
    at file:///Users/tomohiro/workspace/blog/jest.config.js:1:1
    at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
    at async Loader.import (internal/modules/esm/loader.js:166:24)
    at async requireOrImportModule (/Users/tomohiro/workspace/blog/node_modules/jest-util/build/requireOrImportModule.js:65:32)
    at async readConfigFileAndSetRootDir (/Users/tomohiro/workspace/blog/node_modules/jest-config/build/readConfigFileAndSetRootDir.js:109:22)
    at async readConfig (/Users/tomohiro/workspace/blog/node_modules/jest-config/build/index.js:227:18)
    at async readConfigs (/Users/tomohiro/workspace/blog/node_modules/jest-config/build/index.js:412:26)
    at async runCLI (/Users/tomohiro/workspace/blog/node_modules/@jest/core/build/cli/index.js:220:59)
    at async Object.run (/Users/tomohiro/workspace/blog/node_modules/jest-cli/build/cli/index.js:163:37)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

jest.config.js が CommonJS スタイルなのでエラーになる。

module.exports = {
    preset: 'ts-jest’,
    (省略)
}

jest.config.js も ESM に変更

export default  {
    preset: 'ts-jest’,
    (省略)
};

テスト実行

エラーの内容が変わった => Must use import to load ES Module: ~/blog/node_modules/react-markdown/index.js

$ yarn test src/components/post
yarn run v1.22.10
$ NODE_OPTIONS=--experimental-vm-modules jest src/components/post
(node:30646) ExperimentalWarning: VM Modules is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 FAIL  src/components/post/Post/Post.test.tsx
  ● Test suite failed to run

    Must use import to load ES Module: ~/blog/node_modules/react-markdown/index.js

       5 | import { PrismAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
       6 | import { css } from '@emotion/react';
    >  7 | import ReactMarkdown, { Options } from 'react-markdown';
         | ^
       8 | import gfm from 'remark-gfm';
       9 | import rehypeRaw from 'rehype-raw';
      10 | import { Post as PostEntity } from '../../../entities/Post';

      at Runtime.requireModule (node_modules/jest-runtime/build/index.js:791:21)
      at Object.<anonymous> (src/components/post/Post/Post.tsx:7:1)
      at Object.<anonymous> (src/components/post/Post/Post.test.tsx:4:1)

TypeScript からコンパイルされた JS のコード が CommonJS の形式でパッケージを読み込んでいるのが原因っぽい。

// この形にコンパイルされて実行されている
const ReactMarkdown = require('react-markdown')

テスト実行。できた!

yarn run v1.22.10
$ NODE_OPTIONS=--experimental-vm-modules jest src/components/post
(node:31330) ExperimentalWarning: VM Modules is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 PASS  src/components/post/Post/Post.test.tsx (6.216 s)
  Post
    ✓ renders post title (67 ms)

"type": "module" に変更したことで next.config.js を上手く扱えず Next.js がビルドできない問題が発生!( ´•̥ω•̥` )

考え方を変更する。
node_modules/react-markdown を babel-jest でコンパイルして ESM => CJS に変更して読み込み可能にする。

ts-jest の presets を ts-jest/presets/js-with-babel に変更する。

TypeScript files (.ts, .tsx) will be transformed by ts-jest to CommonJS syntax, and JavaScript files (.js, jsx) will be transformed by babel-jest.

(.ts, .tsx) は ts-jest で CJS スタイルに変換して、(.js, .jsx) は babel-jest で変換する。

module.exports = {
    preset: 'ts-jest/presets/js-with-babel',
    (省略),
}

jest は デフォルトで /node_modules 配下を変換の対象外としているので、 /node_modules/react-markdown を babel-jest の変換対象として含める必要がある。

https://jestjs.io/ja/docs/configuration#transformignorepatterns-arraystring
module.exports = {
   // /node_modules/react-markdown だけ transform の対象とする
    transformIgnorePatterns: ['/node_modules/(?!react-markdown)/'],
    (省略),
}

babel-jest.babelrc を設定ファイルとして認識してくれないので、babel.config.js にファイル名を変更する。
(ここの仕様でハマっていた)

.babelrc => babel.config.js

babelの設定は Next.js のまま

module.exports = {
    presets: ['next/babel', '@emotion/babel-preset-css-prop'],
};

テスト実行
通った🎉

$ yarn test src/components/post
yarn run v1.22.10
$ jest src/components/post
 PASS  src/components/post/Post/Post.test.tsx (7.847 s)
  Post
    ✓ reners post title (287 ms)

Next.js の ビルド実行
通った🎉

$ yarn build
yarn run v1.22.10
$ next build

(省略)

✨  Done in 33.52s.
ログインするとコメントできます