Open19

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

t-yngt-yng

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

$ 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)
t-yngt-yng

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

t-yngt-yng

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

{
  "type": "module"
}
t-yngt-yng

実行してみる。

$ 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’,
    (省略)
}
t-yngt-yng

jest.config.js も ESM に変更

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

テスト実行

エラーの内容が変わった => 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)

t-yngt-yng

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

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

テスト実行。できた!

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)
t-yngt-yng

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

t-yngt-yng

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

t-yngt-yng

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',
    (省略),
}
t-yngt-yng

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)/'],
    (省略),
}
t-yngt-yng

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

.babelrc => babel.config.js

babelの設定は Next.js のまま

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

テスト実行
通った🎉

$ 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)
t-yngt-yng

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

$ yarn build
yarn run v1.22.10
$ next build

(省略)

✨  Done in 33.52s.
t-yngt-yng

VitestはESMをサポートしているので、可能であればVitestを使うのも選択肢として良さそう
Vitestに置き換えたことで、同じ状況に遭遇した時の都度対応が不要になった

https://vitest.dev/