Nuxt.js测试策略 #
一、测试概述 #
1.1 测试类型 #
| 类型 | 说明 | 工具 |
|---|---|---|
| 单元测试 | 测试独立函数和组件 | Vitest |
| 组件测试 | 测试组件行为 | Vitest + Vue Test Utils |
| 端到端测试 | 测试完整用户流程 | Playwright |
1.2 测试金字塔 #
text
/\
/ \
/ E2E\
/------\
/ 集成 \
/----------\
/ 单元测试 \
/--------------\
二、Vitest配置 #
2.1 安装依赖 #
bash
pnpm add -D vitest @vue/test-utils @nuxt/test-utils happy-dom
2.2 配置Vitest #
vitest.config.ts:
typescript
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
mock: {
intersectionObserver: true
}
}
},
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html']
}
}
})
2.3 添加脚本 #
package.json:
json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
三、单元测试 #
3.1 测试工具函数 #
utils/formatDate.ts:
typescript
export const formatDate = (date: string | Date): string => {
return new Date(date).toLocaleDateString('zh-CN')
}
utils/formatDate.test.ts:
typescript
import { describe, it, expect } from 'vitest'
import { formatDate } from './formatDate'
describe('formatDate', () => {
it('should format date string', () => {
const result = formatDate('2024-01-15')
expect(result).toBe('2024/1/15')
})
it('should format Date object', () => {
const result = formatDate(new Date('2024-01-15'))
expect(result).toBe('2024/1/15')
})
})
3.2 测试组合式函数 #
composables/useCounter.ts:
typescript
export const useCounter = (initialValue = 0) => {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return { count, increment, decrement, reset }
}
composables/useCounter.test.ts:
typescript
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('should initialize with custom value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('should increment count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('should decrement count', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('should reset count', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
reset()
expect(count.value).toBe(5)
})
})
四、组件测试 #
4.1 测试基础组件 #
components/BaseButton.vue:
vue
<template>
<button :class="['btn', `btn-${variant}`]" :disabled="disabled">
<slot />
</button>
</template>
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary'
disabled?: boolean
}
withDefaults(defineProps<Props>(), {
variant: 'primary',
disabled: false
})
</script>
components/BaseButton.test.ts:
typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from './BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toBe('Click me')
})
it('applies variant class', () => {
const wrapper = mount(BaseButton, {
props: {
variant: 'secondary'
}
})
expect(wrapper.classes()).toContain('btn-secondary')
})
it('can be disabled', () => {
const wrapper = mount(BaseButton, {
props: {
disabled: true
}
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('emits click event', async () => {
const wrapper = mount(BaseButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
})
4.2 测试组件交互 #
components/Counter.vue:
vue
<template>
<div>
<span data-testid="count">{{ count }}</span>
<button data-testid="increment" @click="increment">+</button>
<button data-testid="decrement" @click="decrement">-</button>
</div>
</template>
<script setup lang="ts">
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>
components/Counter.test.ts:
typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('displays initial count', () => {
const wrapper = mount(Counter)
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('increments count', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
it('decrements count', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="decrement"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('-1')
})
})
4.3 测试Nuxt组件 #
typescript
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import MyComponent from './MyComponent.vue'
describe('MyComponent', () => {
it('renders with Nuxt context', async () => {
const wrapper = await mountSuspended(MyComponent, {
route: '/test'
})
expect(wrapper.html()).toContain('Expected content')
})
})
五、端到端测试 #
5.1 安装Playwright #
bash
pnpm add -D @playwright/test
npx playwright install
5.2 配置Playwright #
playwright.config.ts:
typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})
5.3 编写E2E测试 #
e2e/auth.spec.ts:
typescript
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Dashboard')
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
await expect(page.locator('.error-message')).toBeVisible()
})
})
5.4 测试页面导航 #
e2e/navigation.spec.ts:
typescript
import { test, expect } from '@playwright/test'
test.describe('Navigation', () => {
test('should navigate to about page', async ({ page }) => {
await page.goto('/')
await page.click('a[href="/about"]')
await expect(page).toHaveURL('/about')
await expect(page.locator('h1')).toContainText('About')
})
test('should navigate using browser back button', async ({ page }) => {
await page.goto('/')
await page.goto('/about')
await page.goBack()
await expect(page).toHaveURL('/')
})
})
5.5 测试API #
e2e/api.spec.ts:
typescript
import { test, expect } from '@playwright/test'
test.describe('API', () => {
test('should fetch users', async ({ request }) => {
const response = await request.get('/api/users')
expect(response.ok()).toBeTruthy()
const users = await response.json()
expect(Array.isArray(users)).toBe(true)
})
test('should create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'Test User',
email: 'test@example.com'
}
})
expect(response.ok()).toBeTruthy()
const user = await response.json()
expect(user.name).toBe('Test User')
})
})
六、测试覆盖率 #
6.1 配置覆盖率 #
vitest.config.ts:
typescript
export default defineVitestConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'.nuxt/',
'**/*.test.ts',
'**/*.spec.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
})
6.2 运行覆盖率 #
bash
npm run test:coverage
七、Mock和Stub #
7.1 Mock API #
typescript
import { describe, it, expect, vi } from 'vitest'
describe('API Mock', () => {
it('should mock fetch', async () => {
const mockData = { id: 1, name: 'Test' }
global.$fetch = vi.fn().mockResolvedValue(mockData)
const result = await $fetch('/api/test')
expect(result).toEqual(mockData)
})
})
7.2 Mock组合式函数 #
typescript
import { describe, it, expect, vi } from 'vitest'
vi.mock('#imports', () => ({
useState: vi.fn((key, init) => ref(init()))
}))
describe('with mocked useState', () => {
it('should work', () => {
// Test code
})
})
八、最佳实践 #
8.1 测试命名 #
typescript
describe('ComponentName', () => {
describe('methodName', () => {
it('should do something when condition', () => {
// Test
})
})
})
8.2 测试结构 #
typescript
describe('Feature', () => {
beforeEach(() => {
// Setup
})
afterEach(() => {
// Cleanup
})
it('should work', () => {
// Arrange
const input = 'test'
// Act
const result = process(input)
// Assert
expect(result).toBe('expected')
})
})
8.3 测试数据 #
typescript
const createMockUser = (overrides = {}): User => ({
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
})
it('should work with mock user', () => {
const user = createMockUser({ role: 'admin' })
// Test
})
九、CI集成 #
9.1 GitHub Actions #
yaml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm test
- run: pnpm test:e2e
十、总结 #
本章介绍了 Nuxt.js 测试策略:
- Vitest 配置和单元测试
- 组件测试
- Playwright 端到端测试
- 测试覆盖率
- Mock 和 Stub
- 最佳实践
- CI 集成
测试是保证代码质量的重要手段,合理的测试策略可以显著提升开发效率。
最后更新:2026-03-28