harryのブログ

ロードバイクとか模型とかゲームについて何か書いてあるかもしれません

CRAからViteへの移行 - テスト移行編

次回: CRAからViteへの移行 - Vite移行編 - harryのブログ

前書き

  • 個人開発のSPAでcreate-react-app(以下CRA)を使っていたが、少し前ついに最新版に更新できないライブラリが出てきてしまった。
  • ので、重い腰を上げて移行作業を始めた。
  • が、テスト移行だけでしんどい事になってきたので、忘れないうちに記事を書く。
    • つらすぎたので既にVite移行完了後はVitestへ移行することを心に決めつつある。

以下の記事で言及されている通り、テストをCRA依存からjest単体へ移行することは、Vite移行とは独立して作業できます。 そのため、まずはこちらから始めます。

移行前環境

https://github.com/harry0000/last-origin-unit-viewer/blob/v1.2.20/package.json

  • CRA: 5.0.1
    • jest: 27.5.1
  • React: 18.2.0
  • TypeScript: 5.3.3

ビルド環境

Node.js: 20系

移行作業

手順

  1. 対象リポジトリを作業ディレクトリとは異なる適当な場所にcloneして npm run eject を実行
    • このdiffを参考に必要な設定を確認する
  2. 必要なpackageのインストール
  3. Babel & jestの設定
  4. 個別のエラー対応など
  5. ライブラリアップデート

必要なpackageのインストール

ejectで出たdiffとjest公式ドキュメントを参考に必要なpackageをインストールする。

jestjs.io

参照するのは "Setup without Create React App" の所。jestはlatestを指定しないと27.x系が入ってしまう。

npm install --save-dev jest@latest babel-jest @babel/preset-env @babel/preset-react react-test-renderer

これ以外に必要なpackageは以下の通り。

@babel/preset-typescript

  • TypeScriptで書いているため。

jest-environment-jsdom

  ● Test suite failed to run

    TypeError: Cannot read properties of undefined (reading 'html')

      at new JSDOMEnvironment (node_modules/jest-environment-jsdom/build/index.js:72:44)

identity-obj-proxy

Babel & jestの設定

以下を参考に設定ファイルを追加する。

  • jest公式ドキュメント
  • 現在のpackage.jsonjest の設定
    • package.jsonから削除して jest.config.js に移動する。
  • eject後のpackage.jsonbabeljest の設定
    • それぞれ必要な設定を babel.config.jsjest.config.js に移動する。

Vitestへ移行する前提で設定ファイルはtsではなくjsで書いてます。

package.json

-    "test": "react-scripts test",
+    "test": "jest",
-  "jest": {
-    "transformIgnorePatterns": [
-      "/node_modules/(?!@react-dnd|react-dnd|dnd-core)"
-    ]
-  },

babel.config.js

  • plugins の設定は書いてないとテスト実行時にめっちゃwarningが出る。
    • ので結局書くことになる。
module.exports = {
  plugins: [
    ['@babel/plugin-transform-class-properties', { 'loose': true }],
    ['@babel/plugin-transform-private-methods', { 'loose': true }],
    ['@babel/plugin-transform-private-property-in-object', { 'loose': true }]
  ],
  presets: [
    'react-app',
    '@babel/preset-env',
    ['@babel/preset-react', { runtime: 'automatic' }],
    '@babel/preset-typescript'
  ]
};

jest.config.js

  • 可能な限り必要最小限な設定にしてあります。
module.exports = {
  roots: [
    '<rootDir>/src'
  ],
  testMatch: [
    '**/?*\\.test\\.(ts|tsx)'
  ],
  modulePaths: [],
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '\\.css$': 'identity-obj-proxy'
  },
  transform: {
    '^.+\\.[jt]sx?$': 'babel-jest'
  },
  transformIgnorePatterns: [
    '/node_modules/(?!@react-dnd|react-dnd|dnd-core)'
  ],
  setupFilesAfterEnv: [
    '<rootDir>/src/setupTests.ts'
  ]
};

.github/workflows/ci.yml

CI 環境変数が不要になるはず。

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup
      - name: Test
-        run: CI=true npm test
+        run: npm test

個別のエラー対応

SVGファイルのエラー

Reactコンポーネントのテストで以下のエラーが出ましたが、SVGファイルimportの考慮を忘れていました。*1

   Details:

    D:\_git\last-origin-unit-viewer\src\component\icon\TwitterSocialIcon.svg:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){<?xml version="1.0" encoding="utf-8"?>
                                                                                      ^

    SyntaxError: Unexpected token '<'

       8 | import { Copy } from '../icon/FluentIcons';
       9 | import SVGButton from '../common/SVGButton';
    > 10 | import { ReactComponent as TwitterSocialIcon } from '../icon/TwitterSocialIcon.svg';
         | ^

jest.config.jsの moduleNameMapperSVGファイルのmockを設定します。

jest.config.js

  moduleNameMapper: {
    '\\.css$': 'identity-obj-proxy',
+    '\\.svg$': '<rootDir>/src/__mock__/svgMock.tsx'
  },

src/__mock__/svgMock.tsx

import React from 'react';

const SvgMock: React.FC = (props) => (<svg {...props} />);
export default SvgMock;

ちなみに、ejectしたプロジェクトでは涙ぐましい努力によって解決されていました。

config/jest/fileTransform.js

'use strict';

const path = require('path');
const camelcase = require('camelcase');

// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html

module.exports = {
  process(src, filename) {
    const assetFilename = JSON.stringify(path.basename(filename));

    if (filename.match(/\.svg$/)) {
      // Based on how SVGR generates a component name:
      // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
      const pascalCaseFilename = camelcase(path.parse(filename).name, {
        pascalCase: true,
      });
      const componentName = `Svg${pascalCaseFilename}`;
      return `const React = require('react');
      module.exports = {
        __esModule: true,
        default: ${assetFilename},
        ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
          return {
            $$typeof: Symbol.for('react.element'),
            type: 'svg',
            ref: ref,
            key: null,
            props: Object.assign({}, props, {
              children: ${assetFilename}
            })
          };
        }),
      };`;
    }

    return `module.exports = ${assetFilename};`;
  },
};

nanoid のエラー

render系のテストだけ以下のエラーで落ちました。

 FAIL  src/App.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.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • 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:

    D:\_git\last-origin-unit-viewer\node_modules\nanoid\index.browser.js:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){import { urlAlphabet } from './url-alphabet/index.js'
                                                                                      ^^^^^^

    SyntaxError: Cannot use import statement outside a module

      1 | import React from 'react';
    > 2 | import { nanoid } from 'nanoid';
        | ^

はい、react-dndでも起きてた問題です。*2

github.com

nanoidをトランスパイル設定を追加しました。

jest.config.js

  transformIgnorePatterns: [
-    '/node_modules/(?!@react-dnd|react-dnd|dnd-core)'
+    '/node_modules/(?!@react-dnd|react-dnd|dnd-core|nanoid)'
  ],

自分はお行儀悪くCRAに入ってるnanoidをそのまま使ってるので、後の移行作業で最新版を使うように変える必要がありそうです。

% npm ls nanoid
last-origin-unit-viewer@1.2.20 D:\_git\last-origin-unit-viewer
`-- react-scripts@5.0.1
  `-- postcss@8.4.31
    `-- nanoid@3.3.7

ライブラリアップデート

最後に、@testing-library/jest-dom が 5.17.0 と古くなっているため、現最新の 6.2.0 にアップデートしておきます。

npm i -D @testing-library/jest-dom@latest

色々 Breaking Change があったようですが、現状 root がエラー無くレンダリングされることを確認するテストしか行っていないため、特に問題なくアップデートできました。

補足

  • @babel/plugin-proposal-private-property-in-object はES2022から @babel/preset-env に含まれているため、人によっては不要になるかもしれない(?)

  • process.env.PUBLIC_URL に結果が依存するテストを書いてると、テストが落ちるかも。

    • ejectしたプロジェクトでは、scripts/test.jsで空文字列で初期化されている。
    • そもそも結果が環境に依存しないテストに直せるなら直した方がよさそう。
    • 最悪、src\setupTests.tsで process.env.PUBLIC_URL に必要な初期化を行う。

移行が完了して

ここまで作業したところで、テストがすべて正常に通るようになりました。やったね。

% npm run test

> last-origin-unit-viewer@1.2.20 test
> jest

 PASS  src/data/unitBasicData.test.ts
 PASS  src/domain/skill/SkillAreaOfEffectMatcher.test.ts
 PASS  src/data/equipmentData.test.ts
 PASS  src/App.test.tsx                                                                                                                                                                                                                                      

Test Suites: 4 passed, 4 total
Tests:       103 passed, 103 total
Snapshots:   0 total
Time:        4.972 s, estimated 13 s
Ran all test suites.

そして作業がつらすぎたので、Vitestがよさそうな感じだったら移行したい。将来的にViteと一緒に捨てることになっても、その時一番よさそうなテストライブラリに移行すればいいだけなので…。


*1:公式ドキュメントをよく読んでなかったとも言う。

*2:Jest SyntaxError: Unexpected token 'export' · Issue #3443 · react-dnd/react-dnd · GitHub