テスト Vitest + Playwright
MegaShopは公開されましたが, 毎回のリリースにバグがあります。カート計算エラー, 時折発生するログイン失敗, 商品ページの404エラーです。Charlieは自動テストシステムを構築し, Vitestで単体テスト, Playwrightでエンドツーエンド (E2E)テストを行い, 毎回のリリースで信頼できる品質を確保することにしました。
1. 学ぶ内容
- @nuxt/test-utils:Nuxt 3公式テストツールチェーン
- Vitest単体テスト:コンポーネント, Composable, Storeのテスト
- Playwright E2Eテスト:ページインタラクション + SSR検証
- APIテスト:$fetchでサーバーAPIをテスト
- MegaShopカートワークフローE2E + usePriceFormat単体テスト
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) テスト階層
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
mountSuspendedとmountの違いは何ですか?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のみ実行します。
📖 まとめ
- テストピラミッド:大量の単体テスト + 適度な統合テスト + 最小限のE2Eテスト
- Vitest + @nuxt/test-utilsでComposables, Stores, コンポーネントをテスト。Nuxtコンテキストをサポート
- mountSuspendedでNuxt環境にコンポーネントをマウント。自動インポートとプラグインが自動利用可能
- Playwright E2Eテストで重要なユーザーワークフローをテスト。SSR検証はJavaScript無効で可能
- APIテストでは
$fetchでサーバーAPIレスポンスを直接検証
📝 練習問題
- 基本問題 (難易度:⭐):Vitestと@nuxt/test-utilsをインストールし,
usePriceFormatの単体テストを5つ書いてください。 - 応用問題 (難易度:⭐⭐):
cartStoreの完全なCRUDテストとProductCardのコンポーネントテストを書いてください。 - チャレンジ (難易度:⭐⭐⭐):Playwrightでカートの完全なE2Eテストを書いてください:商品閲覧 → カートに追加 → カート表示 → 注文
---|



