AKARI Tech Blog

燈株式会社のエンジニア・開発メンバーによる技術ブログです

Vitest と GitHub Copilot で Frontendの単体テストを書く

改めまして、燈株式会社 AI SaaS事業本部VPoEの小田川です。

燈株式会社のデジタルビルダーでは最近、フロントエンドのテストコードの拡充を行っています。

このテスト拡充の背景には、デジタルビルダーが請求書処理を含む業務フローにおいて高度なカスタマイズを許容しているという特性があります。

このカスタマイズの多様性により、「どの条件で何が表示されるべきか」「各ボタンがどのように機能すべきか」といった振る舞いのパターンが増え、仕様の複雑性も高まっています。

こうした仕様を品質保証(QA)工程にのみ依存するのではなく、開発の初期段階から自動化されたユニットテストで担保し、CI上で高速かつ頻繁に実行されることが、品質とスピードの両立には不可欠と判断しました。

また、今は生成AIが当たり前の時代となり、テストコードの生成がどんどん楽になっています。だからこそ、どういったふうにテストコードをかけばいいのか、Vitestの機能がどのようになっているか振り返っていきたいと思います。

Vitestとは

Vitest は、Viteを開発したチームが作った超高速なユニットテストフレームワークです。特に以下のような特徴があります。

  • Viteとの高い親和性:設定ゼロでViteプロジェクトにスムーズに統合できます
  • Jest互換のAPIdescribe, it, expect など、Jestユーザーにも馴染みやすい構文です。
  • 高速なホットリロードとウォッチモード:開発中のフィードバックループを最大限高速化。Viteで用いられているesbuild (Go実装)をフルに活かし、コードが書き換えられた際に即時で実行されるという良い開発体験を得られます。
  • TypeScript・ESModuleに対応:Webの技術を使っていてCommonJSからESModuleへの対応に苦労されている方も多いかと思います。Vitestはほとんど意識せずにESModuleに移行することができます。

Vite + React / Vue などのプロジェクト(Remixを含む)で、Jestよりも軽くて早く、モダンなテスト環境を求めるなら、Vitestはまさに最適解です。


Vitestの始め方

では、実際にVitestを導入してみましょう。以下はReact + Vite + TypeScript プロジェクトを前提とした導入手順です。

1. Vitestとその他必要なパッケージのインストール

Vitestの他はReactのフロントエンドを書く。

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

2. vite.config.ts にテスト設定を追加

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// ここに vitest の設定を追加
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts', // 任意
  },
})

3. setup ファイル (任意)

React の場合、以下のように jest-dom を使う場合は setup ファイルを作成します:

import '@testing-library/jest-dom'

4. package.jsonスクリプトに追加

{
    // ...
    "scripts": {
      "test": "vitest",
      "test:watch": "vitest --watch"
    }
    // ...
}

Vitestの基本

Vitestのテストコードの基本構造は、以下のような形になります:

基本構文

import { describe, it, expect } from 'vitest'

describe('数学ユーティリティ', () => {
  it('足し算ができること', () => {
    expect(1 + 2).toBe(3)
  })

  it('引き算ができること', () => {
    expect(5 - 2).toBe(3)
  })
})

Jestとほぼ同じ文法なので、Jest経験者はスムーズに移行できます。

UI表示内容の検証を目的としたフロントエンドテストの基本構造

当社では、フロントエンドの品質向上および変更耐性の高い実装を目指し、ユニットテスト・統合テストの拡充を進めています。特に、React + Vite + Vitest + Testing Library の構成においては、(特定のパラメータのもとで)「DOMに特定のテキストが正しく表示されること・されないこと」を軸としたUIテストを多数導入しています。

本記事では、フロントエンドのテストにおける「テキスト表示の検証」をテーマに、社内で標準化しているテストパターンや、その設計意図についてご紹介します。


コンポーネント単体におけるテキスト表示の検証

UIコンポーネントの最も基本的な振る舞いの一つが、「引数や状態に応じて、正しいテキストが表示されること」です。

以下は、ユーザー名を受け取って挨拶文を表示するシンプルなReactコンポーネントに対するテストです。

実装例

// Welcome.tsx
export const Welcome: React.FC<{ name: string }> = ({ name }) => {
  return <p>こんにちは、{name}さん!</p>
}

テストコード

// Welcome.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Welcome } from './Welcome'

describe('Welcome', () => {
  it('指定した名前が表示されること', () => {
    render(<Welcome name="佐藤" />)
    expect(screen.getByText('こんにちは、佐藤さん!')).toBeInTheDocument()
  })
})

このテストは、実行時にDOM内に「こんにちは、佐藤さん!」というテキストが実際に存在しているかを検証しています。

ユーザー操作に伴う状態変化の表示確認

表示テキストは常に静的とは限りません。ユーザー操作やAPIレスポンス、状態遷移により動的に変化することが多く、状態に応じた表示内容が正しいことの検証も重要です。

以下は、ボタンを押すことでメッセージが表示されるコンポーネントに対するテストです。

実装例

// SubmitForm.tsx
import React, { useState } from 'react'

export const SubmitForm = () => {
  const [submitted, setSubmitted] = useState(false)

  return (
    <><button onClick={() => setSubmitted(true)}>送信</button>
      {submitted && <p>送信しました</p>}
    </>
  )
}

テストコード

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SubmitForm } from './SubmitForm'

it('送信後にメッセージが表示されること', async () => {
  render(<SubmitForm />)
  await userEvent.click(screen.getByText('送信'))
  expect(screen.getByText('送信しました')).toBeInTheDocument()
})

ユーザーの操作を userEvent.click() によってシミュレートし、操作後のDOM変化を getByText() によって検証する構造です。ユーザー視点での操作と期待結果を直感的に記述できる点がこのスタイルの利点です。


表示内容のマッチ方法と柔軟な記述パターン

表示内容が完全一致しないケースや、変動する部分を含む場合には、柔軟なマッチ方法が必要になります。

正規表現による部分一致

expect(screen.getByText(/佐藤/)).toBeInTheDocument()
expect(screen.getByText(/こんにちは、.*さん!/)).toBeInTheDocument()

コールバックによる動的な判定

expect(screen.getByText((content) => content.startsWith('こんにちは'))).toBeInTheDocument()

これにより、動的なメッセージやテンプレート文字列を含む表示に対しても、過剰にテストを壊さず柔軟にカバーすることが可能です。


フロントエンドにおけるテスト設計の最小単位としての「文字列検証」

テストカバレッジを闇雲に追求するのではなく、表示内容がビジネス的・体験的に意味を持つポイントを明示的にテストすることが、保守性と信頼性を両立する鍵となります。

当社では以下のような基準で「文字列検証によるUIテスト」を設計しています:

検証対象 具体例 優先度
状態変化の明示 ボタン押下後のメッセージ表示
APIレスポンスの反映 ログインユーザー名の表示
入力エラー時の警告 「この項目は必須です」など
装飾目的の文言 静的なタイトルやヘッダー 低(基本的に対象外)

このように、意味のあるユーザーインターフェースの変化を、ユニットレベルで確認可能にすることが、当社のUIテスト設計方針の基盤となっています。

APIレスポンスを伴うUIの検証におけるモックの活用方法

現代のフロントエンドアプリケーションにおいて、UIの多くは非同期のAPIレスポンスに基づいて構築されます。ユーザー情報の取得、一覧データの描画、状態の変化など、通信を起点としたUI表示はSPAについて欠かせないものとなっています。

そのため、テストにおいても「APIレスポンスに依存するUIの状態をどう検証するか」は避けて通れません。

当社では、Vitest におけるモック機能(vi.fn(), vi.mock())を用いて、API層の副作用を完全に隔離した上でUIだけをテストする設計方針を採用しています。


モックを用いたUI表示のテスト構造

想定ユースケース

  • APIで取得したユーザー名を画面に表示する
  • 通信完了までは「読み込み中...」を表示する
  • エラー時は「エラーが発生しました」を表示する

実装例:非同期APIでユーザー名を表示するコンポーネント

// api.ts(実際のAPI通信を行う層)
export const fetchUserName = async (): Promise<string> => {
  const res = await fetch('/api/user')
  const data = await res.json()
  return data.name
}
// UserGreeting.tsx
import { useEffect, useState } from 'react'
import { fetchUserName } from './api'

export const UserGreeting = () => {
  const [name, setName] = useState<string | null>(null)
  const [error, setError] = useState<boolean>(false)

  useEffect(() => {
    fetchUserName()
      .then(setName)
      .catch(() => setError(true))
  }, [])

  if (error) return <p>エラーが発生しました</p>
  if (name === null) return <p>読み込み中...</p>

  return <p>こんにちは、{name}さん!</p>
}

テスト実装:fetchUserName をモックする

モックテストコード

// UserGreeting.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { UserGreeting } from './UserGreeting'
import * as api from './api'

describe('UserGreeting', () => {
  it('正常にユーザー名が表示される', async () => {
    vi.spyOn(api, 'fetchUserName').mockResolvedValue('田中')

    render(<UserGreeting />)

    // 非同期に表示されるのを待つ
    await waitFor(() => {
      expect(screen.getByText('こんにちは、田中さん!')).toBeInTheDocument()
    })
  })

  it('APIエラー時にエラーメッセージが表示される', async () => {
    vi.spyOn(api, 'fetchUserName').mockRejectedValue(new Error('network error'))

    render(<UserGreeting />)

    await waitFor(() => {
      expect(screen.getByText('エラーが発生しました')).toBeInTheDocument()
    })
  })

  it('初期状態で読み込み中メッセージが表示される', () => {
    // モック不要。初期描画時のみを確認
    render(<UserGreeting />)
    expect(screen.getByText('読み込み中...')).toBeInTheDocument()
  })
})

モックテストのポイントと設計意図

モックテストでは、以下のツール関数を活用することができます。

vitestのツール関数 機能
vi.spyOn(...) 実モジュールの関数を差し替える。安全にモック可能
mockResolvedValue(...) 成功時の戻り値を即座に返す(通信なし)
mockRejectedValue(...) 通信失敗時のUI挙動を再現可能
waitFor(...) 非同期でUIが更新されるのを待って検証する

これにより、UIのロジックと通信の結果を切り離して、状態ごとの振る舞いだけを安全に検証することができます。

フロントエンドのUIテストでは、「APIが返す値に応じてUIがどう変化するか」を検証するケースが多く、副作用のある処理をモック化して状態を制御するテスト構造が重要になります。

Vitestの vi.fn()vi.spyOn() を活用することで、外部要因に依存せず、UIの振る舞いをピンポイントに保証するテストが実現できます。

生成AI × テストコードの未来

生成AIの登場により、テストコードの「作成」作業は誰でも高速にできる時代になりました。ただし、生成されたコードが本当に使えるのかどうか?それを見極める目と設計力が、今後のエンジニアには必須です。

本章では、実際にCopilotを使ってテストコードを生成しながら、「生成AIの出力をどう扱うべきか?」をハンズオン形式で学びます。

Step 1:簡単なReactコンポーネントを作成

まずは次のようなコードを想定します。簡単にtimeというパラメータを受け取って

// Greeting.tsx
export const Greeting: React.FC<{ time: 'morning' | 'evening' }> = ({ time }) => {
  return (
    <p>
      {time === 'morning' ? 'おはようございます!' : 'こんばんは!'}
    </p>
  )
}

Step 2:GitHub Copilotにテストコードを生成させる

GitHub Copilotを用いたエディターとしてVSCodeを用いている場合は、VSCode上で⌘ + Iをおし/tests 日本語で などとお願いしましょう。するとテストコードのファイルを作成してくれます。

生成されたテスト(例):

// Greeting.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Greeting } from './Greeting'

describe('Greeting', () => {
  it('morning の場合に「おはようございます!」が表示される', () => {
    render(<Greeting time="morning" />)
    expect(screen.getByText('おはようございます!')).toBeInTheDocument()
  })

  it('evening の場合に「こんばんは!」が表示される', () => {
    render(<Greeting time="evening" />)
    expect(screen.getByText('こんばんは!')).toBeInTheDocument()
  })
})

Step 3:生成されたテストコードをレビューする

ここで 「AIに任せきりにしない目」 が重要です。例えば以下のようなチェック項目が挙げられます。

レビュー観点チェックリスト

| チェック項目 | 理由 | | --- | --- | | テストケースが仕様を網羅しているか? | morning / evening 以外に unexpected な値に対する考慮は? | | 実装のリファクタに強いテストか? | DOM構造の変化に影響されない getByText か? queryByRole の方がいい場面も。 queryByRole は「UIとしての意味」がある要素にアクセシビリティに関わるWAI-ARIAのrole属性を付与し、それでどの要素に紐づいているかを取得する。 | | await の漏れはないか? | 非同期処理が絡むケースでは waitForfindBy* を使う必要あり ( waitForは、表示されるまでまで待つ。findBy* findByText findByRole など、非同期に表示される要素を待って取得するクエリ await をつけて使う(内部的に一定時間リトライする)、該当要素が画面に出るまで getBy*と同じAPIで探し続ける) |

レビュー:既存テストコードのチェックリスト評価

例えば、このAI生成コードをレビューするとしたら、以下のようにまとめられるでしょう。

チェック項目 評価 コメント
仕様を網羅しているか? ⚪︎ morningevening の両ケースをカバー。ただし異常系(unexpected props)については網羅していない。
リファクタに強いか? getByText は文言変更やタグ変更に弱い。例えば <h1> に変えるとテストが壊れる。セマンティックな getByRole を使うと堅牢。
await の漏れがないか? ⚪︎ 同期的な描画のため await は不要。問題なし。

Step 4: テストコードをレビューして改善する。

以下は、セマンティックに <p role="paragraph"> のような構造を想定した場合の堅牢なテスト例です。

// Greeting.tsx(少しだけHTMLに role を付与した改善案)
export const Greeting: React.FC<{ time: 'morning' | 'evening' }> = ({ time }) => {
  return (
    <p role="status">
      {time === 'morning' ? 'おはようございます!' : 'こんばんは!'}
    </p>
  )
}
// Greeting.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Greeting } from './Greeting'

describe('Greeting', () => {
  it('morning の場合に「おはようございます!」が表示される', () => {
    render(<Greeting time="morning" />)
    const greeting = screen.getByRole('status')
    expect(greeting).toHaveTextContent('おはようございます!')
  })

  it('evening の場合に「こんばんは!」が表示される', () => {
    render(<Greeting time="evening" />)
    const greeting = screen.getByRole('status')
    expect(greeting).toHaveTextContent('こんばんは!')
  })

  it('異常系(unexpected props)は fallback で evening として扱われる', () => {
    render(<Greeting time={'unknown' as any} />)
    const greeting = screen.getByRole('status')
    expect(greeting).toHaveTextContent('こんばんは!') // デフォルト fallback を evening にしている前提
  })
})

このように書くことでより、仕様変更にも強いフロントエンドの単体テストを書くことができます。

まとめ

Vitestは、Viteとの高い親和性・高速性・Jest互換のAPIを備えた、現代的なテストフレームワークです。本記事では、静的なテキスト検証から、状態変化・APIレスポンスを含むUI挙動のテストまで、React + Vitest + Testing Library の実践的なテストパターンを紹介しました。

CopilotなどのAIにより、テストコードの「生成」は飛躍的に効率化されています。しかし、価値あるテストとは「仕様に準拠し、変更に強く、意図を説明できること」に尽きます。

だからこそ、AIをただの自動化ツールではなく、意図と設計の補助輪として活用することが重要です。

何を、なぜテストするのか。その判断こそがエンジニアに求められる本質であり、信頼できるプロダクトを支える土台になります。

We’re Hiring!

燈では、AIを活用しながらも「なぜそれをテストするのか」「なぜその設計にしたのか」といった本質に向き合う開発を大切にしています。

VitestやTesting Libraryを用いたUIテスト、ADRによる意思決定、LLMを組み込んだ開発プロセスなど、仕様理解から設計・検証までを一貫して経験できる環境があります。

技術を「ただ使う」のではなく、「価値ある判断」を積み重ねていく。そんなソフトウェア・エンジニアリングに挑戦したい方、ぜひ一度カジュアルにお話ししましょう!

akariinc.co.jp