2025年のテストフレームワーク事情 - Vitest × Playwright で構築する鉄壁のテスト環境

2025年のテストフレームワーク事情 - Vitest × Playwright で構築する鉄壁のテスト環境

作成日:

「テスト、書いてる?」

この質問に自信を持って「はい!」と答えられるエンジニアは、どれくらいいるでしょうか。

正直に言います。昔の自分は書いてませんでした。

「テスト書く時間あるなら、機能開発したい」「手動でテストすればいいじゃん」そんな言い訳をしてた。

でも、ある日、大規模なリファクタリングをしたとき、全てが崩壊しました。

どこが壊れたか分からない。直したら別の場所が壊れる。手動テストでは追いつかない。

「テスト、書いておけば良かった...」

それから、テストの重要性を痛感し、このブログでは最初からテストを書くことにしました。

そして、2025年現在、最高のテストフレームワークを導入しています。

2025年、テストフレームワークの勢力図

まず、現在のフロントエンド界隈でどんなテストフレームワークが使われているか、整理します。

ユニットテスト・コンポーネントテスト

1. Vitest - 今、最も熱い

公式サイト: https://vitest.dev/

  • 爆速: Viteベースで、テストの起動・実行が超高速
  • Jest互換: Jestからの移行が簡単
  • TypeScript完全サポート: 型チェックも完璧
  • モダン: ESM、Top-level awaitなど、最新機能に対応
  • 開発体験: HMR(Hot Module Replacement)でテストが即座に再実行

人気の理由:

起動速度: Jest(5秒) → Vitest(0.5秒)
実行速度: Jest(10秒) → Vitest(2秒)

この差は大きい。待ち時間が減ると、テストを書くモチベーションが上がります。

2. Jest - 依然として人気

公式サイト: https://jestjs.io/

  • 実績: 長年使われてきた安定性
  • 豊富なエコシステム: プラグインやライブラリが充実
  • ドキュメント: 日本語含め、情報が豊富

でも、遅い:

  • 大規模プロジェクトでは起動に10秒以上
  • CJS(CommonJS)ベースで、ESMへの移行が大変

3. Testing Library - テスト支援ライブラリ

公式サイト: https://testing-library.com/

これはテストフレームワークというより、テスト支援ライブラリ

VitestやJestと組み合わせて使います。

  • ユーザー視点: DOMのテストをユーザー目線で書ける
  • アクセシビリティ重視: getByRoleなど、意味のあるクエリ
  • ベストプラクティス: 実装の詳細に依存しないテスト

このブログでも採用しています。

E2E(End-to-End)テスト

1. Playwright - これも最強

公式サイト: https://playwright.dev/

  • 高速: 並列実行で爆速
  • 安定: リトライ、自動待機で安定したテスト
  • クロスブラウザ: Chrome、Firefox、Safari、すべてテスト可能
  • 開発ツール: UI Mode、Trace Viewer、Codegen(自動生成)

Cypressとの違い:

Playwright: ブラウザ外から操作(速い、柔軟)
Cypress: ブラウザ内で実行(直感的、制限あり)

2. Cypress - 依然として人気

公式サイト: https://www.cypress.io/

  • 開発体験: リアルタイムリロード、タイムトラベルデバッグ
  • 直感的: ブラウザで実行されるので、挙動が分かりやすい

でも、制限が多い:

  • iframeやタブが苦手
  • 並列実行には有料プラン必要
  • クロスブラウザテストも有料

3. Puppeteer - Google製

公式サイト: https://pptr.dev/

  • 柔軟: ブラウザを完全にコントロール
  • スクレイピングにも使える: テスト以外の用途も

でも、テストには向かない:

  • E2Eテスト専用ではない
  • アサーションライブラリが別途必要
  • Playwrightの方が高機能

結論:2025年のベストチョイス

const bestChoice2025 = {
  unit: 'Vitest + Testing Library',
  e2e: 'Playwright',
  reason: '速い、安定、開発体験が最高',
};

このブログでは、この組み合わせを採用しています。

このブログのテスト構成

構成図

blog.printemps.tokyo/
├── components/
│   ├── __tests__/          # Vitestのコンポーネントテスト
│   │   ├── Footer.test.tsx
│   │   ├── Header.test.tsx
│   │   └── PostCard.test.tsx
│   ├── Footer.tsx
│   ├── Header.tsx
│   └── PostCard.tsx
├── lib/
│   ├── __tests__/          # Vitestのユニットテスト
│   │   └── posts.test.ts
│   └── posts.ts
├── e2e/                    # PlaywrightのE2Eテスト
│   ├── blog-post.spec.ts
│   ├── home.spec.ts
│   └── navigation.spec.ts
├── vitest.config.ts        # Vitestの設定
└── playwright.config.ts    # Playwrightの設定

テスト戦略

  1. ユニットテスト(Vitest): 関数、ユーティリティの単体テスト
  2. コンポーネントテスト(Vitest + Testing Library): UIコンポーネントのテスト
  3. E2Eテスト(Playwright): ユーザーフローの統合テスト

Vitestの実装:コンポーネントテスト

インストール

npm install -D vitest @vitejs/plugin-react jsdom
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

設定ファイル(vitest.config.ts)

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',  // ブラウザ環境をシミュレート
    globals: true,          // describe, it, expect をグローバルに
    setupFiles: ['./vitest.setup.ts'],  // セットアップファイル
    include: ['**/__tests__/**/*.{test,spec}.{ts,tsx}'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './'),
    },
  },
})

セットアップファイル(vitest.setup.ts)

import '@testing-library/jest-dom'

これで、toBeInTheDocument()などのマッチャーが使えます。

実際のテストコード:PostCard

このブログの記事カードコンポーネントをテストしてみます。

import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PostCard } from '../PostCard'
import { Post } from '@/.contentlayer/generated'

// テスト用のモックデータ
const mockPost: Post = {
  _id: 'test-post.md',
  _raw: {
    sourceFilePath: 'test-post.md',
    sourceFileName: 'test-post.md',
    sourceFileDir: '.',
    contentType: 'markdown',
    flattenedPath: 'test-post',
  },
  type: 'Post',
  title: 'テスト記事',
  date: '2025-10-24',
  description: 'これはテスト記事の概要です',
  tags: ['テスト', 'Next.js'],
  published: true,
  slug: 'test-post',
  url: '/blog/test-post',
  body: {
    raw: '# Test',
    html: '<h1>Test</h1>',
  },
}

describe('PostCard', () => {
  it('記事情報が正しく表示される', () => {
    render(<PostCard post={mockPost} />)
    
    // タイトルが表示されているか
    expect(screen.getByText('テスト記事')).toBeInTheDocument()
    
    // 概要が表示されているか
    expect(screen.getByText('これはテスト記事の概要です')).toBeInTheDocument()
  })

  it('タグが表示される', () => {
    render(<PostCard post={mockPost} />)
    
    expect(screen.getByText('テスト')).toBeInTheDocument()
    expect(screen.getByText('Next.js')).toBeInTheDocument()
  })

  it('リンクが正しく設定されている', () => {
    render(<PostCard post={mockPost} />)
    
    const link = screen.getByText('テスト記事').closest('a')
    expect(link).toHaveAttribute('href', '/blog/test-post')
  })

  it('概要がない場合でもレンダリングできる', () => {
    const postWithoutDescription = { ...mockPost, description: undefined }
    render(<PostCard post={postWithoutDescription} />)
    
    // エラーにならず、タイトルは表示される
    expect(screen.getByText('テスト記事')).toBeInTheDocument()
  })
})

ポイント

  1. ユーザー視点でテスト: getByTextでテキストから要素を取得
  2. エッジケース: descriptionがない場合も考慮
  3. リンクの検証: closest('a')で親要素を取得

実際のユニットテスト:posts.ts

記事を取得するユーティリティ関数のテスト:

import { describe, expect, it, vi } from 'vitest'
import { 
  getPublishedPosts, 
  getPostBySlug, 
  getPostsByTag, 
  getAllTags 
} from '../posts'

// Contentlayerの生成ファイルをモック
vi.mock('@/.contentlayer/generated', () => ({
  allPosts: [
    {
      slug: 'test-post-1',
      title: 'Test Post 1',
      date: '2025-10-25',
      tags: ['test', 'typescript'],
      published: true,
      body: { html: '<p>Test content</p>' },
    },
    {
      slug: 'test-post-2',
      title: 'Test Post 2',
      date: '2025-10-24',
      tags: ['test', 'react'],
      published: true,
      body: { html: '<p>Test content 2</p>' },
    },
    {
      slug: 'draft-post',
      title: 'Draft Post',
      date: '2025-10-26',
      tags: ['draft'],
      published: false,  // 下書き
      body: { html: '<p>Draft content</p>' },
    },
  ],
}))

describe('posts utility functions', () => {
  describe('getPublishedPosts', () => {
    it('公開されている記事のみを返す', () => {
      const posts = getPublishedPosts()
      
      // 公開記事は2つ
      expect(posts).toHaveLength(2)
      
      // すべてpublished: true
      expect(posts.every((post) => post.published !== false)).toBe(true)
    })

    it('日付の新しい順でソートされている', () => {
      const posts = getPublishedPosts()
      
      // 10/25の記事が先
      expect(posts[0].slug).toBe('test-post-1')
      expect(posts[1].slug).toBe('test-post-2')
    })
  })

  describe('getPostBySlug', () => {
    it('スラッグから記事を取得できる', () => {
      const post = getPostBySlug('test-post-1')
      
      expect(post).toBeDefined()
      expect(post?.title).toBe('Test Post 1')
    })

    it('存在しないスラッグの場合は undefined を返す', () => {
      const post = getPostBySlug('non-existent')
      expect(post).toBeUndefined()
    })
  })

  describe('getPostsByTag', () => {
    it('指定したタグを持つ記事を返す', () => {
      const posts = getPostsByTag('test')
      expect(posts).toHaveLength(2)
    })

    it('該当するタグがない場合は空配列を返す', () => {
      const posts = getPostsByTag('nonexistent')
      expect(posts).toHaveLength(0)
    })
  })
})

モックの活用

vi.mock()で、Contentlayerの生成ファイルをモック化。

これで、テスト用のデータを自由にコントロールできます。

Playwrightの実装:E2Eテスト

インストール

npm install -D @playwright/test
npx playwright install

設定ファイル(playwright.config.ts)

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',          // E2Eテストのディレクトリ
  fullyParallel: true,       // 並列実行
  retries: process.env.CI ? 2 : 0,  // CIでは2回リトライ
  reporter: 'html',          // HTMLレポート
  
  use: {
    baseURL: 'http://localhost:9001',
    trace: 'on-first-retry', // 失敗時のトレース
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
})

実際のE2Eテスト:blog-post.spec.ts

記事詳細ページのテスト:

import { test, expect } from '@playwright/test'

test.describe('記事詳細ページ', () => {
  test('記事の内容が正しく表示される', async ({ page }) => {
    // ページにアクセス
    await page.goto('/blog/welcome')
    
    // タイトルが表示される
    await expect(
      page.getByRole('heading', { 
        name: '技術ブログ始めました - Next.js × Tailwind CSS で構築' 
      })
    ).toBeVisible()
    
    // 作成日が表示される
    await expect(page.getByText('作成日:')).toBeVisible()
    await expect(page.getByText(/2025年10月24日/)).toBeVisible()
    
    // タグが表示される
    await expect(page.getByText('お知らせ')).toBeVisible()
    await expect(page.getByText('Next.js')).toBeVisible()
    
    // 本文の見出しが表示される
    await expect(
      page.getByRole('heading', { name: '技術ブログ始めました' })
    ).toBeVisible()
  })

  test('コードブロックが正しくレンダリングされる', async ({ page }) => {
    await page.goto('/blog/welcome')
    
    // コードブロックが存在する
    const codeBlock = page.locator('pre code')
    await expect(codeBlock).toBeVisible()
  })

  test('存在しない記事にアクセスすると404ページが表示される', async ({ page }) => {
    const response = await page.goto('/blog/non-existent-post')
    
    // 404ステータスコード
    expect(response?.status()).toBe(404)
  })
})

Playwrightの強力な機能

1. 自動待機

// 要素が表示されるまで自動で待つ
await expect(page.getByText('Hello')).toBeVisible()

// クリックできるようになるまで自動で待つ
await page.getByRole('button', { name: 'Submit' }).click()

手動でsleep()とか書く必要なし!

2. Codegen - テストコード自動生成

npx playwright codegen http://localhost:9001

ブラウザが開いて、操作すると自動でコードが生成されます。

3. UI Mode - デバッグが楽

npx playwright test --ui

GUIでテストを実行・デバッグできます。

4. Trace Viewer - タイムトラベル

失敗したテストのトレースを見れば、何が起きたか一目瞭然。

npx playwright show-trace trace.zip

テストの実行方法

Vitestの実行

# 全テスト実行
npm test

# 監視モード(ファイル変更時に自動実行)
npm test -- --watch

# UI Mode(ブラウザでテスト実行)
npm run test:ui

# カバレッジ取得
npm test -- --coverage

Playwrightの実行

# 全テスト実行
npm run test:e2e

# UI Modeで実行
npm run test:e2e:ui

# 特定のテストだけ実行
npx playwright test blog-post

# デバッグモード
npx playwright test --debug

実際に運用してみた感想

良かった点

1. リファクタリングが怖くない

「この変更、他に影響ないよな...?」

という不安がゼロ。テストが通れば、安心してマージできます。

2. バグを早期発見

本番にデプロイする前に、バグを見つけられます。

実際、E2Eテストで「404ページが正しく表示されない」バグを見つけました。

3. ドキュメントになる

テストコードを見れば、コンポーネントの使い方が分かります。

// このテストを見れば、PostCardの使い方が分かる
it('記事情報が正しく表示される', () => {
  render(<PostCard post={mockPost} />)
  expect(screen.getByText('テスト記事')).toBeInTheDocument()
})

4. 開発速度が上がる

「テスト書く時間がもったいない」と思ってたけど、逆でした。

手動テストの時間が減って、トータルで速くなりました。

イマイチな点

1. 最初のセットアップに時間がかかる

設定ファイル、モック、ヘルパー関数...最初は大変です。

でも、一度作れば、あとは楽。

2. E2Eテストは遅い

Vitestは秒で終わりますが、Playwrightは数十秒かかります。

並列実行で高速化できますが、それでも時間はかかる。

3. メンテナンスコスト

UIが変わったら、テストも変更が必要。

ただし、これは「仕様変更に気づける」というメリットでもあります。

テストを書く文化を作るコツ

1. 小さく始める

いきなり全部テストを書こうとしない。

ステップ1: 重要な関数だけユニットテスト
ステップ2: 主要なコンポーネントにテスト
ステップ3: クリティカルなフローにE2Eテスト

2. テストしやすいコードを書く

テストが書きにくいコードは、設計が悪い証拠。

// テストしにくい
function processData() {
  const data = fetchFromAPI()  // 外部依存
  const result = complexLogic(data)
  saveToDatabase(result)  // 副作用
  return result
}

// テストしやすい
function complexLogic(data: Data): Result {
  // 純粋関数:外部依存なし、副作用なし
  return processedData
}

3. CIに組み込む

GitHub Actionsなどで、自動テスト実行。

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm install
      - run: npm test
      - run: npm run test:e2e

4. カバレッジを気にしすぎない

「カバレッジ100%目指そう!」は、本末転倒。

重要なのは、価値のあるテストを書くこと

70-80%のカバレッジで、重要な部分を守る > 
100%のカバレッジで、意味のないテストを書く

まとめ:テストは投資

テストを書くのは、未来の自分への投資です。

テストを書くことで得られるもの:

  1. 安心感: リファクタリングが怖くない
  2. 品質: バグが減る
  3. 速度: 手動テストの時間が減る
  4. ドキュメント: コードの使い方が明確
  5. 自信: 自分のコードに自信が持てる

このブログのテスト構成:

const testStack = {
  unit: 'Vitest',
  component: 'Vitest + Testing Library',
  e2e: 'Playwright',
  coverage: '約75%',
  philosophy: '重要な部分を確実に守る',
};

こんな人におすすめ:

  • テストを始めたい人: Vitestは簡単
  • Jestから移行したい人: ほぼそのまま移行できる
  • Cypressから移行したい人: Playwrightは高速
  • 品質を重視する人: テストは必須

実装の難易度:

  • Vitest: ★★☆☆☆(簡単)
  • Playwright: ★★★☆☆(中程度)
  • 効果: ★★★★★(絶大)

最後に: テストを書くのは面倒かもしれません。でも、書かないことの方がもっと面倒です。

「あの時テスト書いておけば良かった...」と後悔する前に、今日から始めましょう。

参考リンク:

次回は、CIでのテスト自動化について書く予定です。お楽しみに!