見出し画像

BarrelをやめたらVitestが2倍以上速くなった

こんにちは、Webエンジニアのtomoです。

弊社では長らくJestをテストツールとして使用していましたが、JestはCJSを前提として動作するため、昨今のESMへの移行には十分に対応できていません。
そこで、ESMをサポートしているVitestにテストツールを移行したのですが、Jest使用時に比べてテストのCI時間が大幅に増加し、約5分から約11分に伸びてしまいました。

ボトルネックを調査したところ、Barrelが原因となっていることがわかり、Barrelファイルを廃止するだけでJest時代と同じか、少し速い4分51秒というCI時間まで削減することができました。

本記事ではJestからVitestに移行した背景や、ボトルネックの調査、Barrelファイルの具体的な廃止手順について解説します。

背景

使用技術

  • next: 14.2.5

  • jest: 29.7.0

  • vitest: 1.6.0

  • eslint: 8.56.0

  • @uppy/core: 3.13.1

  • @mui/material: 5.15.6

UppyというJSのFile Uploaderライブラリを導入した際、Jestのテストが失敗するようになりました。
原因は、JestがCJS前提で動作するのに対し、Uppy関連のライブラリがESMモジュールであるためです。

JestでもESMモジュールを扱う方法はあるにはあるのですが、あくまで実験的なサポート *1 に留まっており、完全な解決策とは言えません。

弊社ではViteは使用していませんが、Vitestは他のビルドツール上でも動作するとされており、Jestとほぼ同じようなエコシステムを用いているため、移行のハードルも低いです。 *2

このため、Vitestが移行に適していると判断し、JestからVitestへの移行を決めました。

vitest.config.ts の例(Viteを使用していない場合は、vite.config.ts の代わりに vitest.config.ts を設定ファイルとして使用できます)

/// <reference types="vitest" />

import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)']
  }
})

Vitestのテストパフォーマンスの問題点

さて、JestからVitestに移行した際、テストパフォーマンスの問題が生じました。

Vitestはテストの実行が完了すると、以下の項目ごとにどのぐらい時間がかかったかを表示してくれます。

  • transform

  • setup

  • collect

  • tests

  • environment

  • prepare

移行した直後は、この中で特にcollectの処理時間が495.32秒と、テスト実行時間の約74%を占めており、明らかなボトルネックとなっていました。

collectが遅い原因として、テストするために必要なファイルを収集するプロセスに時間がかかっている可能性が考えられました。

そこでVitestのリポジトリを調べてみると、「一般的にBarrelファイルは使用しないでください」といったイシューコメントを見つけました。 *3
試しに @mui/icons-material からのインポートを全て以下のように変更してみました。

// before
import { OpenInNewIcon } from '@mui/icons-material'

// after
import OpenInNewIcon from '@mui/icons-material/OpenInNew'

すると、なんとこれだけで約4分のテスト時間削減に成功しました。

Barrelファイルの利点と欠点

Barrelとは、複数のモジュールを一つのファイルにまとめてエクスポートする方法です。
その際に作成する、複数のモジュールを再エクスポートするためのファイルのことを、ここではBarrelファイルと呼びます。

たとえば、以下のように複数のコンポーネントを個別にエクスポートしている場合、

// components/atoms/Button.js
export const Button = () => { /* ... */ }

// components/atoms/Input.js
export const Input = () => { /* ... */ }

// components/atoms/Tooltip.js
export const Tooltip = () => { /* ... */ }

Barrelファイルを以下のように作成することで、

// components/atoms/index.js(これがBarrelファイル)
export { Button } from './Button'
export { Input } from './Input'
export { Tooltip } from './Tooltip'

他のファイルでは、以下のようにBarrelファイルを通じて一括してインポートすることができます。

import { Button, Input, Tooltip } from './components/atoms'

Barrelファイルを使うことで、インポート文が簡潔になり、コードが整理されやすくなるという利点があります。

しかし、プロジェクトが大規模になると、Barrelファイルが依存関係を複雑化させ、同じ名前のモジュールが複数存在する場合、名前の競合が発生しやすくなったり、今回のようにテストやビルドのパフォーマンスに悪影響を与えたりする場合があります。

特にVitestのようなテストツールでは、Barrelファイルを介してインポートすると、Barrelファイルでエクスポートされているすべてのファイルが収集される可能性があります。
これが「collect」時間の増加につながり、テスト実行のボトルネックとなることが考えられます。

実際に、Vitestのリポジトリ内で「Barrelファイルの使用は公式に推奨されていない」とのコメントが見られるため、こうした懸念があることは認識されています。 *3

よって弊社では、Barrelファイルを使うことより廃止することのメリットが大きく上回ると判断しました。

Barrelファイル廃止の具体的な手順

弊社で廃止を決断したBarrelファイルは、以下の3つです。

  • @mui/icons-material

  • @mui/material

  • atoms,molecules,organisms,pagesそれぞれのBarrelファイル(Atomic Design)

設定は、.eslintrc.js に以下のコードを追加するだけで完了です。
これにより、Quick Fixでも個別インポートを提案してくれるようになります。

'no-restricted-imports': [
  'error',
  {
    paths: ['@mui/icons-material', '@mui/material']
  }
]

あとは手間がかかりますが、Barrelファイルからインポートしている箇所を、ESLintが通るように全て修正すれば完了です。

また、Barrelファイルを廃止して個別インポートに変えると、どのモジュールからインポートしているのか可読性が落ちる可能性があります。
そこで、弊社では eslint-plugin-simple-import-sort というライブラリを使用し、インポート順を整理することで可読性を向上させています。

以下は、.eslintrc.js での設定例です。

plugins: ['simple-import-sort'],
rules: {
  'simple-import-sort/imports': 'error',
  'simple-import-sort/exports': 'error'
}

この設定により、インポートの順序を自動的に整理し、コードの一貫性を保つことができます。

Barrelファイル廃止の判断基準とツールの活用法

Barrelファイルを廃止する際には、基本的にバンドルサイズやimport sizeが大きいもの、ファイル数が多いものから検討するのが良いでしょう。
特に、テストの実行時間に影響を与える可能性が高いものを優先的に廃止することをお勧めします。

バンドルサイズについては @next/bundle-analyzer、import sizeについてはVS Codeの拡張機能「Import Cost」などを使うと便利です。

ちなみに、Jestを使用していた頃は、Next.jsの modularizeImports(Next.js 13.5以降では、optimizePackageImports に統合)という機能を活用してテストの高速化を実現していました。*4

Barrelファイル廃止によるテスト実行時間の変化

最終的なbefore/afterは以下の通りです。

before

  • transform:4.83s

  • setup:26.09s

  • collect:495.32s

  • tests:53.62s

  • environment:57.89s

  • prepare:9.51s

after

  • transform:5.86s

  • setup:21.59s

  • collect:130.14s

  • tests:52.29s

  • environment:55.52s

  • prepare:8.81s

collect以外はほとんど変化がありませんが、特にcollectの改善が顕著であり、これが全体のテスト時間短縮に大きく寄与しています。

ビルドや、OpenAPI SchemaからOrvalによるTypeScriptの型定義やmockコードの生成などを含むCI全体の時間も、以下のグラフのように大幅に短縮されました。

まとめ

今回の取り組みにより、テストの実行時間を11分から4分51秒まで削減できたことには大変満足しています。

ただし、まだ改善の余地は残されています。
たとえば、Fakerからのインポートについて、全ロケールからのインポートを特定のロケールに絞ることで、さらなるサイズ削減が可能です。

// 全ロケールからインポート
import { faker } from '@faker-js/faker' // 2.9M(gzipped: 968.9k)

// jaロケールのみに絞ってインポート
import { faker } from '@faker-js/faker/locale/ja' // 547.1k(gzipped: 178.8k)

さらに、OpenAPIから生成した型情報やAPIについても、Barrelファイルを通してエクスポートしている現状を見直すことで、テスト実行時間のさらなる削減が期待できると考えています。

また、Vitestのsetupやenvironmentの処理にも一定の時間がかかっているため、これらについても最適化を検討しています。
今後も継続的に改善を進めていく予定です。

参考文献

*1:https://jestjs.io/ja/docs/ecmascript-modules
*2:https://vitest.dev/guide/comparisons.html#jest
*3:https://github.com/vitest-dev/vitest/issues/579#issuecomment-1946462435
*4:https://github.com/mui/material-ui/issues/36218