👫

AIと二人三脚でJestからVitestへ移行したお話

に公開

はじめに

株式会社Linc’well フロントエンド基盤チームです。

私たちのメインプロダクトのフロントエンド開発環境は create-react-app (CRA) ベースで約6年運用してきました。
しかし、CRAはすでにメンテナンスされておらず、周辺ライブラリの更新も難しい状況です。そこで基盤刷新の一環として Vite への移行 を決定し、同時にテストランナーも Jest → Vitest へ切り替えることになりました。

今回お話しするのはJest → Vitest への移行のお話です。

対象となるテストファイルは200以上。通常であれば数週間はかかる作業ですが、今回は Claude Codeを中心にAIを活用することで、わずか一週間程度で移行を完了 することができました。
この記事では、その実体験を「AIとのやりとり」を軸に振り返ります。

※ viteへの移行は別の記事で投稿予定です!

なぜVitestなのか?

はじめにVitestを選定した理由を簡単に説明します。

VitestはViteと高い親和性を持つテストランナーで、以下の特徴があります。

  • Jestの主要なAPIをサポート
  • Viteと設定ファイルを共通化可能
  • 公式のマイグレーションガイドが整備されている

「Jestユーザーが移行しやすいデファクトな選択肢」と言える存在で、特に迷うことなくVitestを選択しました。

https://vitest.dev/

移行の難しさ

マイグレーションガイドだけを読むと簡単そうに見えますが、実際は以下のような課題がありました。

  • 対象ファイルが膨大(200+)
  • 可能な限りビジネスロジックの変更は避けたい
  • モジュールモックや非同期処理など、一筋縄ではいかない差分

手作業ではコンフリクトや工数の増加は避けられません。ここで大きな助けとなったのがClaude Codeでした。

AIとのやりとりで進めた移行プロセス

方針をClaude Codeと相談して決める

当初、私は「1ファイルずつエラーを潰して完全にテストを通しながら進めよう」と考えていました。
しかしClaude Codeから返ってきたのは、

「まずはすべてのimportやAPIを一括で書き換えて、その後エラー毎に分類して、個別に対応した方が効率的です」

という提案でした。私の初意とは違いましたが、試してみると確かに効率的で、後の作業が格段に楽になりました。

単純置換はAIに全て任せる

VitestはJestとの後方互換性が意識されており、同名のAPIが多数用意されています。

そのため、jest.fn()jest.spyOn() といった分かりやすい置換は、すべてAIに任せて一括で対応しました。

- import { jest } from '@jest/globals'
+ import { vi } from 'vitest'

- const mockedHistoryPush = jest.fn()
- const spy = jest.spyOn(window, 'scrollTo')
+ const mockedHistoryPush = vi.fn()
+ const spy = vi.spyOn(window, 'scrollTo')

数百ファイルを機械的に処理してもブレが出ないのは、AIならではの強みでした。

当然この段階ではエラーになったままテストを通過しないファイルが60ファイルほどありました。

個別のエラー対応は「ログ化→再利用」のループ

単純置換では動かないケース(非同期処理、done コールバック、モックの扱いなど)は、人間が原因を切り分け、before/afterをMarkdownに残す ようにしました。

そのMarkdownをAIに読ませると、

「同じエラーが他に10件あります。同様に修正しますか?」

と聞いてくれるので、「はい」と答えるだけで類似修正をまとめて処理できます。
結果的に、この「修正ログ」が自然とマイグレーションノウハウ集になっていきました。

さらに、失敗したテストファイルもログとして書き出すことで、同じ作業を繰り返すときや別のコンテクストでやり直すときにも、話がそれにくくスムーズに進められるようになりました

Code Migration Guide (Sample)

// Import Changes

// Before (Jest)
import { jest } from '@jest/globals'

// After (Vitest)
import { vi } from 'vitest'

// Before (Jest)
const mockFn = jest.fn()
jest.spyOn(object, 'method')
jest.clearAllMocks()

// After (Vitest)
const mockFn = vi.fn()
vi.spyOn(object, 'method')
vi.clearAllMocks()

// Mock Functions

// Before (Jest)
describe('UserService', () => {
  jest.mock('../repositories/userRepository', () => ({
    findUser: jest.fn(() => ({ id: 1, name: 'Alice' }))
  }))

  test('ユーザー取得', () => {
    const user = getUser()
    expect(user.name).toBe('Alice')
  })
})

// After (Vitest)
// ⚠️ Vitestではmockをdescribeの外に書く必要がある
vi.mock('../repositories/userRepository', () => ({
  findUser: vi.fn(() => ({ id: 1, name: 'Alice' }))
}))

describe('UserService', () => {
  test('ユーザー取得', () => {
    const user = getUser()
    expect(user.name).toBe('Alice')
  })
})

// Timers

// Before (Jest)
test('1秒後にフラグがtrueになる', () => {
  jest.useFakeTimers()
  const flag = startTimer()
  jest.advanceTimersByTime(1000)
  expect(flag.isTrue()).toBe(true)
})

// After (Vitest)
test('1秒後にフラグがtrueになる', () => {
  vi.useFakeTimers()
  const flag = startTimer()
  vi.advanceTimersByTime(1000)
  expect(flag.isTrue()).toBe(true)
})

// Module Mocking (importActual)

// Before (Jest)
jest.mock('../utils/formatter', () => ({
  ...jest.requireActual('../utils/formatter'),
  formatPrice: jest.fn(() => '999円')
}))

test('金額フォーマットが上書きされる', () => {
  expect(formatPrice(1000)).toBe('999円')
})

// After (Vitest)
// ⚠️ vi.importActualはasyncなのでawaitが必要
vi.mock('../utils/formatter', async () => {
  const actual = await vi.importActual<typeof import('../utils/formatter')>(
    '../utils/formatter'
  )
  return {
    ...actual,
    formatPrice: vi.fn(() => '999円')
  }
})

test('金額フォーマットが上書きされる', () => {
  expect(formatPrice(1000)).toBe('999円')
})

Failed Test Files (Sample)
This document lists all test files that failed after the initial JestVitest migration.
We used this as a running log to track progress and fixes.

**Total Failed Files: 41** | **Fixed: 13** | **Remaining: 28** | **Progress: 31.7%**

### Example

1. `src/components/example_component.test.tsx`
2. `src/hooks/useExampleHook.test.ts`
3. `~~src/lib/localStorage.test.ts~~` -FIXED (mock setup updated)
4. `src/pages/dashboard/dashboard.test.tsx`

代表的なJest → Vitest変換一覧

作業の中で特に頻出した変換をまとめると以下の通りです。

項目 Jest Vitest 補足
モック関数 jest.fn() vi.fn() spyOn / mock も同様に vi. 系へ
モジュールモック jest.mock('./path', () => 'hello') vi.mock('./path', () => ({ default: 'hello' })) デフォルトエクスポート扱いが異なる
実モジュール参照 jest.requireActual('xxx') await vi.importActual('xxx') 非同期化必須、呼び出し側を async
タイマー jest.useFakeTimers() vi.useFakeTimers() レガシータイマーは未対応
コールバックテスト it('works', (done) => { ... }) it('works', async () => { ... }) または return new Promise(...) done コールバックは非推奨
スナップショット expect(value).toMatchInlineSnapshot(\\Array [ "abc" ]`)` expect(value).toMatchInlineSnapshot(\\[ "abc" ]`)` 出力フォーマットが変わる
型定義 jest.Mock<T> import type { Mock } from 'vitest' 型はVitestから直接 import

AIでも解決できなかった部分

もちろんすべてがAIで片付いたわけではありません。

  • フレーキーなテスト
  • プロダクトコードに深く依存するテスト

これらはAIだけでは対応できず、どうしようもなく skip を入れたりリファクタを検討する必要がありました。

逆に言えば、AIで簡単に置換できない箇所は、テストの妥当性やビジネスロジックの複雑さが潜んでいることが多く、それを見直す良いきっかけにもなった と感じています

結果と学び

  • 200以上のテストファイルを3〜4日で移行完了
  • リリース時のコンフリクトはほぼゼロ
  • 手作業なら2〜3週間はかかっていたはずの作業を大幅短縮
  • 途中でProプランでは処理量が足りず、Maxプランに切り替えましたが、上限に当たることはなく快適に進められました。

最大の学びは、AIを単なる作業ツールではなく、ペアプログラマとして活用する意識を持つことでした。

  • 単純な置換や調査はAIに任せる
  • 人間は例外対応や品質判断に集中する

この分担によって短期間での移行が可能になりました。

まとめ

JestからVitestへの移行はサンプルやガイドも多く、技術的には珍しいテーマではありません。
しかし、「AIを活用することで、大規模な移行を短期間でやりきれる」 という体験は新しいものでした。

「AIがなければ一週間で終えることは絶対にできなかった」と断言できます。
今後もAIと二人三脚で、開発体験を加速させていきたいと思います。


弊社ではフロントエンドエンジニアを積極的に採用しています。

コードの負債解消に取り組んでおり、非常にやりがいのある時期です。

AI 活用や医療ドメインに興味のある方は、ぜひ採用ページもご覧ください。

ありがとうございました。

https://recruit.linc-well.com/contact

Linc'well, inc.

Discussion