Nuxt: 测试 Vitest + Playwright

最后更新:2026-08-26

MegaShop 上线了,但每次发布都有 bug——购物车计算错误、登录偶尔失败、商品页 404。Charlie 决定建立自动化测试体系,用 Vitest 覆盖单元测试,Playwright 覆盖 E2E 测试,确保每次发布质量可靠。

1. 你将学到


2. 一个架构师的真实故事

(1) 痛点:手动测试无法覆盖所有场景

Bob 每次发布前手动测试 MegaShop——首页、商品页、购物车、结算、管理后台。5 个浏览器 × 3 种语言 × 20 个页面 = 300 次手动测试。漏测率 30%,每次发布至少 2 个线上 bug。

(2) 自动化测试的解法

Vitest + Playwright 让测试自动化运行:

TYPESCRIPT
// Vitest: unit test for 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 线上 bug

自动化测试覆盖核心流程,CI 每次提交自动运行,漏测率降到 2%,线上 bug 减少到 0。


3. 测试金字塔

(1) 测试层次架构

100%
graph TB
    A[E2E Tests - Playwright<br/>Critical user flows] --> B[Integration Tests<br/>API + Component interactions]
    B --> C[Unit Tests - 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 路由 购物车→结算

4. Vitest 配置与单元测试

▶ 示例:安装与配置

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']
    }
  }
})

▶ 示例: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 📖 仅展示
// 执行成功

▶ 示例: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. 组件测试

▶ 示例: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 测试

▶ 示例: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 📖 仅展示
// 执行成功

▶ 示例:购物车流程 E2E

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 }) => {
    // Navigate to product list
    await page.goto('/products')

    // Wait for products to load
    await page.waitForSelector('.product-card')

    // Click first product's "Add to Cart" button
    await page.locator('.product-card:first-child button').click()

    // Verify cart count updated
    const cartCount = page.locator('.cart-count')
    await expect(cartCount).toHaveText('1')

    // Navigate to cart page
    await page.goto('/cart')

    // Verify item in cart
    await expect(page.locator('.cart-item')).toHaveCount(1)
  })

  test('complete checkout flow', async ({ page }) => {
    // Login first
    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"]')

    // Add item and go to checkout
    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")')

    // Verify checkout page
    await expect(page.locator('h1')).toHaveText(/checkout/i)
  })
})

输出:

TEXT 📖 仅展示
// 执行成功

7. API 测试

▶ 示例:Server 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 Server 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 插件/auto-import/Composable 等完整上下文。
Q E2E 测试太慢怎么办?
A 只测试关键用户流程(购物车/结算/登录),不测试每个页面。组件测试覆盖 UI 逻辑,E2E 覆盖端到端流程。
Q 测试 SSR 行为怎么测?
A 用 @nuxt/test-utils 的 $fetch 直接请求服务端渲染的 HTML,验证 HTML 中是否包含数据。也可用 Playwright 禁用 JS 后检查页面。
Q Pinia Store 测试需要 mock API 吗?
A Store 中调用 $fetch 的 action 需要 mock。用 vitest.mock 拦截 $fetch,或抽取 API 调用到 Composable 便于 mock。
Q CI 中 Vitest 和 Playwright 怎么配合?
A Vitest 在 lint 之后运行(快,1-2 分钟),Playwright 在 build 之后运行(慢,5-10 分钟)。PR 时都跑,push 到 main 时只跑 Vitest。

📖 小节


📝 作业

  1. 基础题(难度⭐):安装 Vitest + @nuxt/test-utils,为 usePriceFormat 编写 5 个单元测试
  2. 进阶题(难度⭐⭐):为 cartStore 编写完整的 CRUD 测试,为 ProductCard 编写组件测试
  3. 挑战题(难度⭐⭐⭐):用 Playwright 编写完整的购物车 E2E 测试:浏览商品 → 加购 → 查看购物车 → 结算

---|

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏