🦁

VitestとJestの共存から始める、無理のないVitest移行

に公開

こんにちは。
スペースマーケットでエンジニアをしている8zkです。

なぜJestからVitestに移行するのか?

突然ですがJestの実行時間が長いと感じたことはありませんか?私はあります。
そこでとても速いと噂のVitestに移行しようと思い立ちました。

Vitestを選ぶ理由は大きく2つです

  • テストの実行が速い
  • Jest互換のAPIが豊富で学習コストが低い

とはいえ、隙間時間にVitest移行を進めようと思った私にはいきなりすべてのテストをVitestに置き換えるのはちょっと重荷です。
まずは1ファイル移行してみて徐々にVitestに移行していきたいと考え、Jestは残したままVitestを少しずつ試すという方法を考えました。

以下のように段階を踏んで移行したいと思います。

  • Vitestを導入して基本設定をする
  • 1ファイルだけVitestで書いてみる
  • 問題なければ、新規テストはVitest、既存はJestで運用
  • 既存テストも徐々に移行

Vitestの導入と設定

vitestをインストールします。

pnpm add -D vitest

vitest.config.tsを用意して下記のように設定します。

vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'), // '@' → 'src' にマッピング
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['**/*.vitest.test.tsx'], // ← 拡張子でvitest.test.tsを指定
  },
})

package.jsonにスクリプトを追加します。

package.json
"scripts": {
  "test": "pnpm run test:jest && pnpm run test:vitest",
  "test:jest": "jest",
  "test:vitest": "vitest run",
},

このようにすることで、下記のように目的別にテストを実行することができます。

# 通常はJestとVitestで実行
pnpm run test

# Jestで実行
pnpm run test:jest

# Vitestで実行
pnpm run test:vitest

APIの置き換え

VitestはJestの互換APIを多く持っていますが、完全に同じではありません。
基本的な置き換えは下記の通りです。

Jest Vitest
jest.fn() vi.fn()
jest.mock() vi.mock()
jest.clearAllMocks() vi.clearAllMocks()
jest.resetAllMocks() vi.resetAllMocks()

JestからVitestにコードを置き換える

index.test.tsx
import { Search } from '..'
import { render, screen, fireEvent } from '@testing-library/react'
import { useFetch } from '@/hooks/api/useFetch'
import { useToast } from '@chakra-ui/react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import dayjs from 'dayjs'

const MOCK_DATA = {
  results: [
    {
      id: 1,
      price: 3300,
      rooms: {
        id: 1,
        name: 'example',
        prefecture: '東京都',
      },
    },
  ],
}

jest.mock('lodash.debounce', () => jest.fn((fn) => fn))
jest.mock('@/hooks/api/useFetch')
const mockedUseFetch = useFetch as jest.Mock

jest.mock('next/navigation', () => ({
  useSearchParams: jest.fn(),
  useRouter: jest.fn(),
  usePathname: jest.fn(),
}))

jest.mock('@chakra-ui/react', () => {
  const actual = jest.requireActual('@chakra-ui/react')

  return {
    ...actual,
    useToast: jest.fn(),
  }
})

const mockedUseToast = useToast as jest.Mock
const mockedUseRouter = useRouter as jest.Mock
const mockedUseSearchParams = useSearchParams as jest.Mock
const mockedUsePathname = usePathname as jest.Mock

describe('@features/routes/Search', () => {
  beforeEach(() => {
    mockedUseFetch.mockReturnValue({ data: [MOCK_DATA], error: null })
    mockedUseToast.mockReturnValue(jest.fn())
    mockedUseRouter.mockReturnValue({
      push: jest.fn(),
    })
    mockedUseSearchParams.mockReturnValue({
      get: jest.fn().mockReturnValue(''),
    })
    mockedUsePathname.mockReturnValue('/')
  })

  test('情報が0件の時、「情報がありません」と表示されること', () => {
    mockedUseFetch.mockReturnValue({ data: [], error: null })
    const { container } = render(<Search />)
    expect(container).toBeInTheDocument()
    expect(screen.getByText('情報がありません')).toBeInTheDocument()
  })
  test('エラーの場合、「リクエストに失敗しました。しばらく経ってからお試しください。」というトーストが表示されること', () => {
    const toastMock = jest.fn()
    mockedUseToast.mockReturnValue(toastMock)
    mockedUseFetch.mockReturnValue({ data: [], error: new Error('Error') })
    render(<Search />)
    expect(toastMock).toHaveBeenCalledWith({
      title: 'リクエストに失敗しました。しばらく経ってからお試しください。',
      status: 'error',
      isClosable: true,
      position: 'top',
    })
  })
  test("キーワードが変更された時、'/search?keyword=渋谷'に遷移すること", () => {
    const pushMock = jest.fn()
    mockedUseRouter.mockReturnValue({
      push: pushMock,
    })
    render(<Search />)
    const input = screen.getByPlaceholderText('駅を入力する')
    fireEvent.change(input, { target: { value: '渋谷' } })
    expect(pushMock).toHaveBeenCalledWith('/search?keyword=%E6%B8%8B%E8%B0%B7')
  })
  test("日付が変更された時、'/search?date=YYYY-MM-DD'に遷移すること", () => {
    const pushMock = jest.fn()
    mockedUseRouter.mockReturnValue({
      push: pushMock,
    })
    render(<Search />)
    const newDate = dayjs().format('M/DD')
    console.log('newDate', newDate)
    const dateButton = screen.getByText(newDate)
    fireEvent.click(dateButton)
    const queryParamsDate = dayjs().format('YYYY-MM-DD')
    expect(pushMock).toHaveBeenCalledWith(`/search?date=${queryParamsDate}`)
  })
})

このテストコードをVitestに置き換えると下記のようになります。

index.vitest.tsx
import { Search } from '..'
import { render, screen, fireEvent } from '@testing-library/react'
import { useFetch } from '@/hooks/api/useFetch'
import { useToast } from '@chakra-ui/react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import dayjs from 'dayjs'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const MOCK_DATA = {
  results: [
    {
      id: 1,
      price: 3300,
      rooms: {
        id: 1,
        name: 'example',
        prefecture: '東京都',
      },
    },
  ],
}

// lodash.debounceをモック
vi.mock('lodash.debounce', () => ({
  default: (fn: any) => fn,
}))

// useFetchをモック
vi.mock('@/hooks/api/useFetch', () => ({
  useFetch: vi.fn(),
}))
const mockedUseFetch = useFetch as unknown as ReturnType<typeof vi.fn>

// next/navigation をモック
vi.mock('next/navigation', () => ({
  useSearchParams: vi.fn(),
  useRouter: vi.fn(),
  usePathname: vi.fn(),
}))
const mockedUseRouter = useRouter as unknown as ReturnType<typeof vi.fn>
const mockedUseSearchParams = useSearchParams as unknown as ReturnType<typeof vi.fn>
const mockedUsePathname = usePathname as unknown as ReturnType<typeof vi.fn>

// Chakra UI の useToast をモック
vi.mock('@chakra-ui/react', async () => {
  const actual = await vi.importActual('@chakra-ui/react')
  return {
    ...actual,
    useToast: vi.fn(),
  }
})
const mockedUseToast = useToast as unknown as ReturnType<typeof vi.fn>

describe('@features/routes/Search', () => {
  beforeEach(() => {
    mockedUseFetch.mockReturnValue({ data: [MOCK_DATA], error: null })
    mockedUseToast.mockReturnValue(vi.fn())
    mockedUseRouter.mockReturnValue({
      push: vi.fn(),
    })
    mockedUseSearchParams.mockReturnValue({
      get: vi.fn().mockReturnValue(''),
    })
    mockedUsePathname.mockReturnValue('/')
  })

  it('情報が0件の時、「情報がありません」と表示されること', () => {
    mockedUseFetch.mockReturnValue({ data: [], error: null })
    const { container } = render(<Search />)
    expect(container).toBeTruthy()
    const text = screen.queryByText('情報がありません')
    expect(text).not.toBeNull()
  })

  it('エラーの場合、「リクエストに失敗しました。しばらく経ってからお試しください。」というトーストが表示されること', () => {
    const toastMock = vi.fn()
    mockedUseToast.mockReturnValue(toastMock)
    mockedUseFetch.mockReturnValue({ data: [], error: new Error('Error') })
    render(<Search />)
    expect(toastMock).toHaveBeenCalledWith({
      title: 'リクエストに失敗しました。しばらく経ってからお試しください。',
      status: 'error',
      isClosable: true,
      position: 'top',
    })
  })

  it("キーワードが変更された時、'/search?keyword=渋谷'に遷移すること", () => {
    const pushMock = vi.fn()
    mockedUseRouter.mockReturnValue({
      push: pushMock,
    })
    render(<Search />)
    const input = screen.getByPlaceholderText('駅を入力する')
    fireEvent.change(input, { target: { value: '渋谷' } })
    expect(pushMock).toHaveBeenCalledWith('/search?keyword=%E6%B8%8B%E8%B0%B7')
  })

  it("日付が変更された時、'/search?date=YYYY-MM-DD'に遷移すること", () => {
    const pushMock = vi.fn()
    mockedUseRouter.mockReturnValue({
      push: pushMock,
    })
    render(<Search />)
    const newDate = dayjs().format('M/DD')
    const dateButton = screen.getByText(newDate)
    fireEvent.click(dateButton)
    const queryParamsDate = dayjs().format('YYYY-MM-DD')
    expect(pushMock).toHaveBeenCalledWith(`/search?date=${queryParamsDate}`)
  })
})

これでpnpm run test:vitestを実行すると...

やったー!テストが通りました!!

徐々に移行する戦略

もちろん一気にVitestに乗り換えるのが理想だと思いますが、日々の仕事の合間に移行をするには時間が取れない・「全部移行しなきゃ!」と意気込むと作業量が多そうで腰が引けるなどの理由で後回しになってしまうことが多いと思います。
なので段階的な移行を行うための戦略を考えました。

新規テストはVitest、既存はJest

新しく書くテストは全てVitest。
修正を加える際にはVitestに移行。
既存のテストは問題がなければそのままJest。

わかりやすくてシンプルなルールだと思います。

まとめ

Vitestは、Jestを完全に捨てなくても使い始めることができます。
プロジェクトによっては完全移行は時間もコストもかかりますが、共存→部分的移行→最終的に一本化というステップを踏めば、スムーズに導入することができると思います。

「Vitest移行したいけど時間がないな〜」と思う同士がいたら、まずは1ファイルだけでもVitestで書いてみることから始めてみませんか?

最後に

スペースマーケットでは一緒に働く仲間を募集しています!
「少し話を聞いてみたい」といった軽い気持ちでも大丈夫なので、ご連絡お待ちしています!

https://jobs.forkwell.com/spacemarket/jobs/28583

スペースマーケット Engineer Blog

Discussion