404 Not Found

404 Not Found


nginx

テスト Vitest + Playwright

MegaShopは公開されましたが, 毎回のリリースにバグがあります。カート計算エラー, 時折発生するログイン失敗, 商品ページの404エラーです。Charlieは自動テストシステムを構築し, Vitestで単体テスト, Playwrightでエンドツーエンド (E2E)テストを行い, 毎回のリリースで信頼できる品質を確保することにしました。

1. 学ぶ内容


2. アーキテクトのある本当の話

(1) 課題:手動テストでは全シナリオをカバーできない

毎回のリリース前, BobはMegaShopを手動テストしています。ホーム, 商品, カート, 注文, 管理画面。5ブラウザ x 3言語 x 20ページ = 300の手動テスト。テストカバレッジのギャップは30%, 毎回のリリースで少なくとも2つの本番バグが見つかります。

(2) 自動テストの解決策

Vitest + Playwright:テスト実行の自動化:

TYPESCRIPT
// Vitest:usePriceFormatの単体テスト
test('formats USD price correctly', () => {
  const price = ref(2999.99)
  const { formatted } = usePriceFormat(price)
  expect(formatted.value).toBe('$2,999.99')
})

(3) 結果:手動テスト0 + 本番バグ0

自動テストがコアプロセスをカバーし, CIが各コミットで自動実行, テスト漏れ率は2%に低減, 本番バグは0になりました。


3. テストピラミッド

(1) テスト階層

100%
graph TB
    A[E2Eテスト - Playwright<br/>重要ユーザーフロー] --> B[統合テスト<br/>API + コンポーネント間連携]
    B --> C[単体テスト - Vitest<br/>Composables / Stores / Utils]
    
    style A fill:#f96,stroke:#333
    style B fill:#ff9,stroke:#333
    style C fill:#9f9,stroke:#333

(2) テスト種別の比較

項目 単体テスト 統合テスト E2Eテスト
ツール Vitest Vitest Playwright
スコープ 単一関数/コンポーネント 複数モジュール間の連携 完全なユーザーフロー
速度 ⚡⚡⚡ 高速 ⚡⚡ 中程度 🐢 遅い
数量
価値 ロジックの正確性 インターフェースの一貫性 ユーザーフローの保証
MegaShopの例 usePriceFormat API Routes カート → 注文

4. Vitest設定と単体テスト

(1) ▶ サンプル:インストールと設定

BASH
npm install -D vitest @nuxt/test-utils @vue/test-utils happy-dom

出力:

TEXT
# コマンド実行成功
TYPESCRIPT
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules', '.nuxt']
    }
  }
})

(2) ▶ サンプル:Composable単体テスト

TYPESCRIPT
// tests/composables/usePriceFormat.test.ts
import { ref } from 'vue'

describe('usePriceFormat', () => {
  test('formats USD price with comma separator', () => {
    const price = ref(2999.99)
    const { formatted } = usePriceFormat(price)
    expect(formatted.value).toBe('$2,999.99')
  })

  test('formats JPY price with no decimals', () => {
    const price = ref(29999)
    const { formatted } = usePriceFormat(price, { currency: 'JPY', locale: 'ja-JP' })
    expect(formatted.value).toBe('¥29,999')
  })

  test('reactively updates when price changes', () => {
    const price = ref(100)
    const { formatted } = usePriceFormat(price)
    expect(formatted.value).toBe('$100.00')
    price.value = 200
    expect(formatted.value).toBe('$200.00')
  })

  test('handles zero price', () => {
    const price = ref(0)
    const { formatted } = usePriceFormat(price)
    expect(formatted.value).toBe('$0.00')
  })

  test('handles raw number input (not ref)', () => {
    const { formatted } = usePriceFormat(50.5)
    expect(formatted.value).toBe('$50.50')
  })
})

出力:

TEXT
// 実行成功

(3) ▶ サンプル:Pinia Storeテスト

TYPESCRIPT
// tests/stores/cart.test.ts
import { setActivePinia, createPinia } from 'pinia'

describe('useCartStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  test('adds item to cart', () => {
    const store = useCartStore()
    store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
    expect(store.items.length).toBe(1)
    expect(store.totalItems).toBe(1)
  })

  test('increments quantity for existing item', () => {
    const store = useCartStore()
    const product = { id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' }
    store.addItem(product)
    store.addItem(product)
    expect(store.items.length).toBe(1)
    expect(store.items[0].quantity).toBe(2)
  })

  test('removes item from cart', () => {
    const store = useCartStore()
    store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
    store.removeItem(1)
    expect(store.items.length).toBe(0)
  })

  test('calculates total price correctly', () => {
    const store = useCartStore()
    store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
    store.addItem({ id: 2, name: 'B', price: 200, image: '/b.jpg' })
    store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' }) // quantity = 2
    expect(store.totalPrice).toBe(400) // 100*2 + 200
  })

  test('clears cart', () => {
    const store = useCartStore()
    store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
    store.clearCart()
    expect(store.items.length).toBe(0)
    expect(store.totalPrice).toBe(0)
  })
})

出力:

TEXT
// 実行成功

5. コンポーネントテスト

(1) ▶ サンプル:mountSuspendedコンポーネントテスト

TYPESCRIPT
// tests/components/ProductCard.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'

describe('ProductCard', () => {
  const mockProduct = {
    id: 1, name: 'Premium Headphones', price: 299.99,
    image: '/headphones.jpg', inStock: true
  }

  test('renders product name and price', async () => {
    const wrapper = await mountSuspended(ProductCard, {
      props: { product: mockProduct }
    })
    expect(wrapper.text()).toContain('Premium Headphones')
    expect(wrapper.text()).toContain('299.99')
  })

  test('emits add-to-cart event on button click', async () => {
    const wrapper = await mountSuspended(ProductCard, {
      props: { product: mockProduct }
    })
    await wrapper.find('button').trigger('click')
    expect(wrapper.emitted('add-to-cart')).toBeTruthy()
    expect(wrapper.emitted('add-to-cart')[0][0]).toEqual(mockProduct)
  })

  test('shows out of stock when not available', async () => {
    const wrapper = await mountSuspended(ProductCard, {
      props: { product: { ...mockProduct, inStock: false } }
    })
    expect(wrapper.text()).toContain('Out of Stock')
  })
})

出力:

TEXT
// 実行成功

6. Playwright E2Eテスト

(1) ▶ サンプル:Playwright設定

TYPESCRIPT
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  baseURL: 'http://localhost:3000',
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI
  },
  use: {
    headless: true,
    screenshot: 'only-on-failure'
  }
})

出力:

TEXT
// 実行成功

(2) ▶ サンプル:エンドツーエンドカートワークフロー

TYPESCRIPT
// e2e/cart-flow.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Shopping Cart Flow', () => {
  test('add product to cart and verify', async ({ page }) => {
    // 商品一覧にナビゲーション
    await page.goto('/products')

    // 商品の読み込みを待機
    await page.waitForSelector('.product-card')

    // 最初の商品の「カートに追加」ボタンをクリック
    await page.locator('.product-card:first-child button').click()

    // カート数の更新を確認
    const cartCount = page.locator('.cart-count')
    await expect(cartCount).toHaveText('1')

    // カートページにナビゲーション
    await page.goto('/cart')

    // カート内アイテムを確認
    await expect(page.locator('.cart-item')).toHaveCount(1)
  })

  test('complete checkout flow', async ({ page }) => {
    // 先にログイン
    await page.goto('/login')
    await page.fill('input[type="email"]', 'alice@example.com')
    await page.fill('input[type="password"]', 'password123')
    await page.click('button[type="submit"]')

    // アイテムを追加して注文に進む
    await page.goto('/products/1')
    await page.click('button:has-text("Add to Cart")')
    await page.click('a:has-text("Cart")')
    await page.click('a:has-text("Proceed to Checkout")')

    // 注文ページを確認
    await expect(page.locator('h1')).toHaveText(/checkout/i)
  })
})

出力:

TEXT
// 実行成功

7. APIテスト

(1) ▶ サンプル:サーバーAPIテスト

TYPESCRIPT
// tests/api/products.test.ts
import { setupTest } from '@nuxt/test-utils'

describe('Products API', () => {
  setupTest()

  test('GET /api/products returns product list', async () => {
    const response = await $fetch('/api/products')
    expect(response.items).toBeDefined()
    expect(response.total).toBeGreaterThan(0)
    expect(response.page).toBe(1)
  })

  test('GET /api/products/:id returns product detail', async () => {
    const product = await $fetch('/api/products/1')
    expect(product.id).toBe(1)
    expect(product.name).toBeDefined()
    expect(product.price).toBeDefined()
  })

  test('GET /api/products/:id returns 404 for invalid id', async () => {
    await expect($fetch('/api/products/99999')).rejects.toThrow('404')
  })

  test('GET /api/products supports pagination', async () => {
    const page1 = await $fetch('/api/products?page=1&limit=5')
    const page2 = await $fetch('/api/products?page=2&limit=5')
    expect(page1.items.length).toBeLessThanOrEqual(5)
    expect(page2.items[0].id).not.toBe(page1.items[0].id)
  })

  test('GET /api/products supports category filter', async () => {
    const result = await $fetch('/api/products?category=electronics')
    result.items.forEach((item: any) => {
      expect(item.category).toBe('electronics')
    })
  })
})

出力:

TEXT
// 実行成功

8. 総合例:MegaShopテストフレームワーク

JSON
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "npm run test && npm run test:e2e"
  }
}
テスト種別 コマンド カバレッジ 実行タイミング
単体テスト vitest Composable/Store/Utils 各コミット
コンポーネントテスト vitest Vueコンポーネント 各コミット
APIテスト vitest サーバーAPI 各コミット
E2Eテスト Playwright ユーザーフロー PRマージ前
カバレッジ vitest --coverage 全体 CIパイプライン

❓ よくある質問

Q VitestとJestの違いは何ですか?
A VitestはViteベースでNuxt 3とビルド設定を共有するため, 起動が高速です。Jestは追加のBabel/webpack設定が必要です。Nuxt 3はVitestの使用を推奨しています。
Q mountSuspendedmountの違いは何ですか?
A mountはVue Test Utilsが提供する基本マウント関数で, mountSuspended@nuxt/test-utilsが提供し, Nuxtプラグイン, 自動インポート, Composableを含む完全なコンテキストをサポートします。
Q E2Eテストが遅すぎる場合はどうすればよいですか?
A 重要なユーザーフロー (カート/注文/ログイン)のみをテストし, すべてのページをテストしないでください。コンポーネントテストがUIロジックをカバーし, E2Eテストがエンドツーエンドのフローをカバーします。
Q SSRの動作をテストするにはどうすればよいですか?
A @nuxt/test-utils$fetchメソッドを使用してサーバーレンダリング済みHTMLを直接リクエストし, HTMLにデータが含まれていることを確認します。PlaywrightでJavaScriptを無効にしてページを検査することも可能です。
Q Pinia StoreテストにモックAPIは必要ですか?
A Store内で呼び出される$fetchアクションはモックする必要があります。vitest.mock$fetchをインターセプトするか, API呼び出しをComposableに抽出してモックしやすくします。
Q CIでVitestとPlaywrightはどのように連携しますか?
A Vitestはlint後に実行 (高速, 1-2分), Playwrightはビルド後に実行 (低速, 5-10分)。プルリクエスト作成時は両方を実行し, メインブランチへのpush時はVitestのみ実行します。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐):Vitestと@nuxt/test-utilsをインストールし, usePriceFormatの単体テストを5つ書いてください。
  2. 応用問題 (難易度:⭐⭐):cartStoreの完全なCRUDテストとProductCardのコンポーネントテストを書いてください。
  3. チャレンジ (難易度:⭐⭐⭐):Playwrightでカートの完全なE2Eテストを書いてください:商品閲覧 → カートに追加 → カート表示 → 注文

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%