Nuxt: 测试 Vitest + Playwright
最后更新:2026-08-26
MegaShop 上线了,但每次发布都有 bug——购物车计算错误、登录偶尔失败、商品页 404。Charlie 决定建立自动化测试体系,用 Vitest 覆盖单元测试,Playwright 覆盖 E2E 测试,确保每次发布质量可靠。
1. 你将学到
- @nuxt/test-utils:Nuxt 3 官方测试工具链
- Vitest 单元测试:组件 + Composable + Store 测试
- Playwright E2E 测试:页面交互 + SSR 验证
- API 测试:$fetch 测试 server API
- MegaShop 购物车流程 E2E + usePriceFormat 单元测试
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) 测试层次架构
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。
📖 小节
- 测试金字塔:大量单元测试 + 适量集成测试 + 少量 E2E 测试
- Vitest + @nuxt/test-utils 测试 Composable/Store/组件,支持 Nuxt 上下文
- mountSuspended 在 Nuxt 环境中挂载组件,auto-import 和插件自动可用
- Playwright E2E 测试关键用户流程,SSR 验证可禁用 JS
- API 测试用 $fetch 直接验证 server API 响应
📝 作业
- 基础题(难度⭐):安装 Vitest + @nuxt/test-utils,为 usePriceFormat 编写 5 个单元测试
- 进阶题(难度⭐⭐):为 cartStore 编写完整的 CRUD 测试,为 ProductCard 编写组件测试
- 挑战题(难度⭐⭐⭐):用 Playwright 编写完整的购物车 E2E 测试:浏览商品 → 加购 → 查看购物车 → 结算
---|