主题测试指南
测试是保证主题质量的重要环节。本文档将介绍如何为 VitePress 主题编写和运行各种类型的测试,包括单元测试、组件测试和 E2E 测试。
版本说明
- 本文档基于 VitePress v1.0.0+ 和 Vitest 1.0+ 编写
- 使用 Vue Test Utils 进行组件测试
- 使用 Playwright 进行 E2E 测试
测试策略
测试金字塔
╱╲
╱E2E╲ E2E 测试(端到端测试)
╱────╲ - 模拟真实用户行为
╱ 组件 ╲ - 测试完整功能流程
╱ 测试 ╲ - 运行成本高,速度慢
╱──────────╲
╱ 单元测试 ╲ 单元测试
╱────────────────╲ - 测试独立函数/组件
- 运行快速,成本低
- 覆盖率高测试类型对比
| 测试类型 | 测试对象 | 运行速度 | 维护成本 | 覆盖范围 |
|---|---|---|---|---|
| 单元测试 | 函数、工具类 | 快 ⚡️ | 低 | 小 |
| 组件测试 | Vue 组件 | 中等 | 中 | 中 |
| E2E 测试 | 完整应用 | 慢 🐢 | 高 | 大 |
环境配置
安装依赖
bash
# 安装 Vitest 和相关测试工具
npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8
# 安装 Playwright(E2E 测试)
npm install -D @playwright/testVitest 配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 全局变量
globals: true,
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'.vitepress/',
'**/*.d.ts',
'**/*.config.*',
'**/types/**'
]
},
// 包含的测试文件
include: [
'**/__tests__/**/*.test.ts',
'**/__tests__/**/*.spec.ts'
],
// 排除的文件
exclude: [
'node_modules',
'dist',
'.vitepress/dist',
'.vitepress/cache'
],
// 设置文件
setupFiles: ['./vitest.setup.ts'],
// 别名
alias: {
'@': resolve(__dirname, './src')
}
}
})测试设置文件
typescript
// vitest.setup.ts
import { config } from '@vue/test-utils'
// 全局组件存根
config.global.stubs = {
// 存根 VitePress 组件
'Content': true,
'ClientOnly': true
}
// 全局 mock
config.global.mocks = {
$frontmatter: {},
$site: {
title: 'Test Site',
description: 'Test Description'
}
}添加脚本
json
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}单元测试
测试工具函数
typescript
// src/utils/formatDate.ts
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
export function getReadingTime(content: string): number {
const wordsPerMinute = 300
const words = content.split(/\s+/).length
return Math.ceil(words / wordsPerMinute)
}typescript
// src/utils/__tests__/formatDate.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, getReadingTime } from '../formatDate'
describe('formatDate', () => {
it('应该格式化日期字符串', () => {
const result = formatDate('2024-01-15')
expect(result).toBe('2024年1月15日')
})
it('应该格式化 Date 对象', () => {
const result = formatDate(new Date('2024-01-15'))
expect(result).toBe('2024年1月15日')
})
it('应该处理无效日期', () => {
const result = formatDate('invalid-date')
expect(result).toBe('Invalid Date')
})
})
describe('getReadingTime', () => {
it('应该计算阅读时间', () => {
const content = 'This is a test content with multiple words.'
const result = getReadingTime(content)
expect(result).toBeGreaterThan(0)
})
it('应该返回至少 1 分钟', () => {
const content = 'Short'
const result = getReadingTime(content)
expect(result).toBe(1)
})
it('应该正确计算长文章', () => {
const content = Array(301).fill('word').join(' ')
const result = getReadingTime(content)
expect(result).toBe(2)
})
})测试 Composables
typescript
// src/composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
doubleCount,
increment,
decrement,
reset
}
}typescript
// src/composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('应该使用初始值初始化', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('默认初始值应该为 0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('应该正确计算 doubleCount', () => {
const { count, doubleCount } = useCounter(5)
expect(doubleCount.value).toBe(10)
count.value = 10
expect(doubleCount.value).toBe(20)
})
it('increment 应该增加 count', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
increment()
expect(count.value).toBe(2)
})
it('decrement 应该减少 count', () => {
const { count, decrement } = useCounter(2)
decrement()
expect(count.value).toBe(1)
})
it('reset 应该重置到初始值', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
})组件测试
测试简单组件
vue
<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<p class="count">{{ count }}</p>
<button class="increment" @click="increment">+1</button>
<button class="decrement" @click="decrement">-1</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
</script>typescript
// src/components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'
describe('Counter', () => {
it('应该渲染初始值', () => {
const wrapper = mount(Counter)
expect(wrapper.find('.count').text()).toBe('0')
})
it('点击 +1 按钮应该增加计数', async () => {
const wrapper = mount(Counter)
await wrapper.find('.increment').trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
it('点击 -1 按钮应该减少计数', async () => {
const wrapper = mount(Counter)
await wrapper.find('.decrement').trigger('click')
expect(wrapper.find('.count').text()).toBe('-1')
})
it('多个操作应该正确更新计数', async () => {
const wrapper = mount(Counter)
await wrapper.find('.increment').trigger('click')
await wrapper.find('.increment').trigger('click')
await wrapper.find('.decrement').trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
})测试带 Props 的组件
vue
<!-- src/components/PostCard.vue -->
<template>
<article class="post-card">
<h2 class="title">{{ post.title }}</h2>
<p class="description">{{ post.description }}</p>
<div class="meta">
<span class="date">{{ formattedDate }}</span>
<span class="reading-time">{{ post.readingTime }} 分钟阅读</span>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Post {
title: string
description: string
date: string
readingTime: number
}
const props = defineProps<{
post: Post
}>()
const formattedDate = computed(() => {
return new Date(props.post.date).toLocaleDateString('zh-CN')
})
</script>typescript
// src/components/__tests__/PostCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PostCard from '../PostCard.vue'
describe('PostCard', () => {
const mockPost = {
title: '测试文章',
description: '这是一篇测试文章',
date: '2024-01-15',
readingTime: 5
}
it('应该渲染文章标题', () => {
const wrapper = mount(PostCard, {
props: { post: mockPost }
})
expect(wrapper.find('.title').text()).toBe('测试文章')
})
it('应该渲染文章描述', () => {
const wrapper = mount(PostCard, {
props: { post: mockPost }
})
expect(wrapper.find('.description').text()).toBe('这是一篇测试文章')
})
it('应该正确格式化日期', () => {
const wrapper = mount(PostCard, {
props: { post: mockPost }
})
expect(wrapper.find('.date').text()).toContain('2024')
})
it('应该显示阅读时间', () => {
const wrapper = mount(PostCard, {
props: { post: mockPost }
})
expect(wrapper.find('.reading-time').text()).toContain('5')
})
it('应该匹配快照', () => {
const wrapper = mount(PostCard, {
props: { post: mockPost }
})
expect(wrapper.html()).toMatchSnapshot()
})
})测试带插槽的组件
vue
<!-- src/components/Card.vue -->
<template>
<div class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div>
</template>typescript
// src/components/__tests__/Card.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Card from '../Card.vue'
describe('Card', () => {
it('应该渲染默认插槽', () => {
const wrapper = mount(Card, {
slots: {
default: '卡片内容'
}
})
expect(wrapper.find('.card-body').text()).toBe('卡片内容')
})
it('应该渲染具名插槽', () => {
const wrapper = mount(Card, {
slots: {
header: '卡片标题',
default: '卡片内容',
footer: '卡片底部'
}
})
expect(wrapper.find('.card-header').text()).toBe('卡片标题')
expect(wrapper.find('.card-footer').text()).toBe('卡片底部')
})
it('没有插槽时不应渲染 header 和 footer', () => {
const wrapper = mount(Card, {
slots: {
default: '内容'
}
})
expect(wrapper.find('.card-header').exists()).toBe(false)
expect(wrapper.find('.card-footer').exists()).toBe(false)
})
})测试事件
vue
<!-- src/components/SearchInput.vue -->
<template>
<input
v-model="searchTerm"
type="text"
class="search-input"
@input="handleInput"
@keyup.enter="handleSearch"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
search: [value: string]
update: [value: string]
}>()
const searchTerm = ref('')
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update', value)
}
function handleSearch() {
emit('search', searchTerm.value)
}
</script>typescript
// src/components/__tests__/SearchInput.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchInput from '../SearchInput.vue'
describe('SearchInput', () => {
it('应该在输入时触发 update 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('.search-input')
await input.setValue('test')
expect(wrapper.emitted('update')).toBeTruthy()
expect(wrapper.emitted('update')[0]).toEqual(['test'])
})
it('应该在按回车时触发 search 事件', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('.search-input')
await input.setValue('search term')
await input.trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['search term'])
})
it('应该绑定 v-model', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('.search-input')
await input.setValue('new value')
expect(input.element.value).toBe('new value')
})
})Mock VitePress
Mock useData
typescript
// __mocks__/vitepress.ts
import { vi } from 'vitest'
export const useData = vi.fn(() => ({
site: {
value: {
title: 'Test Site',
description: 'Test Description'
}
},
page: {
value: {
title: 'Test Page',
frontmatter: {}
}
},
theme: {
value: {}
},
frontmatter: {
value: {}
},
lang: {
value: 'zh-CN'
},
isDark: {
value: false
}
}))
export const useRoute = vi.fn(() => ({
path: '/test',
data: {}
}))
export const useRouter = vi.fn(() => ({
go: vi.fn(),
onBeforeRouteChange: vi.fn(),
onAfterRouteChanged: vi.fn()
}))typescript
// vitest.config.ts
export default defineConfig({
test: {
// ...
alias: {
'vitepress': resolve(__dirname, './__mocks__/vitepress.ts')
}
}
})在测试中使用
typescript
// src/composables/__tests__/useSiteTitle.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSiteTitle } from '../useSiteTitle'
import { useData } from 'vitepress'
vi.mock('vitepress')
describe('useSiteTitle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('应该返回站点标题', () => {
useData.mockReturnValue({
site: { value: { title: 'My Site' } }
})
const { title } = useSiteTitle()
expect(title.value).toBe('My Site')
})
})E2E 测试
Playwright 配置
typescript
// playwright.config.ts
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:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI
}
})E2E 测试示例
typescript
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('导航测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('应该显示首页', async ({ page }) => {
await expect(page).toHaveTitle(/首页/)
await expect(page.locator('h1')).toBeVisible()
})
test('点击导航链接应该跳转', async ({ page }) => {
await page.click('text=关于')
await expect(page).toHaveURL(/.*about/)
await expect(page.locator('h1')).toContainText('关于')
})
test('侧边栏应该正常显示', async ({ page }) => {
const sidebar = page.locator('.VPSidebar')
await expect(sidebar).toBeVisible()
const links = sidebar.locator('a')
const count = await links.count()
expect(count).toBeGreaterThan(0)
})
})
test.describe('搜索功能测试', () => {
test('搜索功能应该正常工作', async ({ page }) => {
await page.goto('/')
// 打开搜索
await page.click('[aria-label="Search"]')
await page.waitForSelector('.DocSearch')
// 输入搜索词
await page.fill('#docsearch-input', 'VitePress')
// 等待搜索结果
await page.waitForSelector('.DocSearch-Hits')
// 验证有搜索结果
const results = page.locator('.DocSearch-Hit')
const count = await results.count()
expect(count).toBeGreaterThan(0)
})
})
test.describe('响应式测试', () => {
test('移动端菜单应该正常工作', async ({ page }) => {
// 设置移动端视口
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
// 点击菜单按钮
await page.click('[aria-label="Toggle Menu"]')
// 验证侧边栏显示
const sidebar = page.locator('.VPSidebar')
await expect(sidebar).toBeVisible()
})
})
test.describe('性能测试', () => {
test('页面加载性能', async ({ page }) => {
const startTime = Date.now()
await page.goto('/')
const loadTime = Date.now() - startTime
console.log(`页面加载时间: ${loadTime}ms`)
expect(loadTime).toBeLessThan(3000)
})
test('Core Web Vitals', async ({ page }) => {
await page.goto('/')
// 获取 LCP
const lcp = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
resolve(lastEntry.startTime)
}).observe({ entryTypes: ['largest-contentful-paint'] })
})
})
console.log('LCP:', lcp)
expect(lcp).toBeLessThan(2500)
})
})视觉回归测试
typescript
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('视觉回归测试', () => {
test('首页快照', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100
})
})
test('文章页快照', async ({ page }) => {
await page.goto('/posts/test-article')
await expect(page).toHaveScreenshot('article.png', {
fullPage: true
})
})
test('深色模式快照', async ({ page }) => {
await page.goto('/')
// 切换到深色模式
await page.click('[aria-label="Toggle Theme"]')
await page.waitForTimeout(500)
await expect(page).toHaveScreenshot('dark-mode.png', {
fullPage: true
})
})
})测试覆盖率
查看覆盖率
bash
# 运行覆盖率测试
npm run test:coverage覆盖率目标
| 类型 | 目标 | 说明 |
|---|---|---|
| 语句覆盖率 | ≥ 80% | 代码语句执行比例 |
| 分支覆盖率 | ≥ 70% | 条件分支覆盖比例 |
| 函数覆盖率 | ≥ 80% | 函数调用比例 |
| 行覆盖率 | ≥ 80% | 代码行覆盖比例 |
CI 集成
GitHub Actions 配置
yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build site
run: npm run build
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/最佳实践
1. 测试命名
typescript
// ✅ 好的命名
describe('useCounter', () => {
it('应该正确增加计数', () => {})
it('应该正确减少计数', () => {})
})
// ❌ 不好的命名
describe('counter', () => {
it('test 1', () => {})
it('test 2', () => {})
})2. 测试结构
typescript
describe('组件名/函数名', () => {
// 前置条件
beforeEach(() => {})
// 正常情况
describe('正常情况', () => {
it('应该...', () => {})
})
// 边界情况
describe('边界情况', () => {
it('应该处理...', () => {})
})
// 错误情况
describe('错误处理', () => {
it('应该抛出错误', () => {})
})
})3. 避免测试实现细节
typescript
// ❌ 测试实现细节
it('应该设置 count 为 1', () => {
const wrapper = mount(Counter)
expect(wrapper.vm.count).toBe(0)
wrapper.vm.increment()
expect(wrapper.vm.count).toBe(1)
})
// ✅ 测试行为
it('应该显示增加后的计数', async () => {
const wrapper = mount(Counter)
expect(wrapper.find('.count').text()).toBe('0')
await wrapper.find('.increment').trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})