VitePress 站点测试指南
测试能确保 VitePress 站点的质量、稳定性和可维护性。本教程涵盖单元测试、E2E 测试、快照测试和可访问性测试。
测试类型概览
| 测试类型 | 工具 | 目的 | 适用场景 |
|---|---|---|---|
| 单元测试 | Vitest | 测试组件和工具函数 | Vue 组件、composables、工具函数 |
| 快照测试 | Vitest | 检测 UI 意外变化 | 组件渲染输出、页面结构 |
| E2E 测试 | Playwright / Cypress | 模拟用户操作 | 页面导航、搜索、主题切换 |
| 可访问性测试 | axe-core / Lighthouse | 检查无障碍合规 | 整站 a11y 审计 |
| 构建测试 | 脚本 | 验证构建产物 | 确保构建无错误 |
项目初始化
安装依赖
bash
# 核心测试依赖
npm install -D vitest @vue/test-utils jsdom
# E2E 测试(推荐 Playwright)
npm install -D @playwright/test
# 可访问性测试
npm install -D axe-core
# 快照序列化
npm install -D vitest-serializer-vueVitest 配置
ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: [
'docs/.vitepress/components/**/*.{vue,ts}',
'docs/.vitepress/composables/**/*.ts',
'docs/.vitepress/theme/**/*.ts'
],
exclude: ['node_modules/', 'docs/.vitepress/dist/']
},
setupFiles: ['./tests/setup.ts']
},
resolve: {
alias: {
'@': resolve(__dirname, 'docs/.vitepress'),
'@components': resolve(__dirname, 'docs/.vitepress/components')
}
}
})测试 Setup 文件
ts
// tests/setup.ts
import { config } from '@vue/test-utils'
// 全局设置
config.global.stubs = {
// 存根 VitePress 组件(在测试环境中不可用)
Content: true,
ClientOnly: true,
RouteLink: true
}package.json 脚本
json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:a11y": "vitest run tests/a11y"
}
}单元测试
测试 Vue 组件
ts
// tests/components/QuizComponent.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import QuizComponent from '../../docs/.vitepress/components/QuizComponent.vue'
describe('QuizComponent', () => {
const questions = [
{
question: 'VitePress 基于 Vite 构建,对吗?',
options: ['对', '不对', '不确定'],
correct: 0,
explanation: 'VitePress 基于 Vite 构建。'
}
]
it('正确渲染题目', () => {
const wrapper = mount(QuizComponent, {
props: {
title: '测试',
questions
}
})
expect(wrapper.find('.quiz-title').text()).toBe('测试')
expect(wrapper.find('.quiz-question').text()).toContain('VitePress')
})
it('点击选项后显示答案解析', async () => {
const wrapper = mount(QuizComponent, {
props: { title: '测试', questions }
})
// 点击正确选项
const options = wrapper.findAll('.quiz-option')
await options[0].trigger('click')
// 应显示解析
expect(wrapper.find('.quiz-explanation').exists()).toBe(true)
expect(wrapper.find('.quiz-explanation').text()).toContain('基于 Vite')
})
it('错误选项显示错误样式', async () => {
const wrapper = mount(QuizComponent, {
props: { title: '测试', questions }
})
const options = wrapper.findAll('.quiz-option')
await options[1].trigger('click') // 点击错误选项
expect(options[1].classes()).toContain('incorrect')
})
})测试 Composable
ts
// tests/composables/useSiteInfo.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSiteInfo } from '../../docs/.vitepress/composables/useSiteInfo'
// Mock vitepress
vi.mock('vitepress', () => ({
useData: () => ({
theme: {
value: {
title: 'Test Site',
description: 'A test description',
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/', activeMatch: '/guide/' }
]
}
},
frontmatter: { value: {} }
})
}))
describe('useSiteInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('返回正确的站点标题', () => {
const info = useSiteInfo()
expect(info.title).toBe('Test Site')
})
it('提取有链接的导航项', () => {
const info = useSiteInfo()
expect(info.navItems).toHaveLength(2)
expect(info.navItems[0].text).toBe('Home')
})
it('过滤掉下拉菜单组(无链接)', () => {
// useData mock 中的 nav 包含子菜单组时
const info = useSiteInfo()
expect(info.navItems.every(item => item.link)).toBe(true)
})
})测试工具函数
ts
// tests/utils/formatDate.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, readingTime } from '../../docs/.vitepress/utils/helpers'
describe('formatDate', () => {
it('格式化日期字符串', () => {
const result = formatDate('2026-03-31')
expect(result).toMatch(/2026/)
})
it('处理空值', () => {
expect(formatDate('')).toBe('')
})
})
describe('readingTime', () => {
it('计算中文文章阅读时间', () => {
const text = '这是'.repeat(200)
const result = readingTime(text)
expect(result).toBe('1 分钟')
})
it('空内容返回 0 分钟', () => {
expect(readingTime('')).toBe('不到 1 分钟')
})
})快照测试
组件快照
ts
// tests/snapshots/KnowledgeGraph.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KnowledgeGraph from '../../docs/.vitepress/components/KnowledgeGraph.vue'
describe('KnowledgeGraph 快照', () => {
it('渲染输出不变', () => {
const wrapper = mount(KnowledgeGraph)
expect(wrapper.html()).toMatchSnapshot()
})
it('不同主题模式渲染不变', () => {
const wrapper = mount(KnowledgeGraph, {
props: { theme: 'dark' }
})
expect(wrapper.html()).toMatchSnapshot('dark-theme')
})
})Markdown 渲染快照
ts
// tests/snapshots/markdown.test.ts
import { describe, it, expect } from 'vitest'
import { createMarkdownRenderer } from 'vitepress'
describe('Markdown 渲染', () => {
it('正确渲染自定义容器', async () => {
const md = await createMarkdownRenderer(
'',
{ themeConfig: {} } as any,
'',
'/base/'
)
const html = await md.render('::: tip\n提示内容\n:::')
expect(html).toMatchSnapshot('tip-container')
})
it('正确渲染代码组', async () => {
const md = await createMarkdownRenderer(
'',
{ themeConfig: {} } as any,
'',
'/base/'
)
const source = '```js\nconsole.log("hello")\n```'
const html = await md.render(source)
expect(html).toContain('language-js')
})
})E2E 测试
Playwright 配置
ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
timeout: 30000,
retries: 1,
reporter: [['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'mobile',
use: { ...devices['iPhone 13'] }
}
],
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI
}
})基础页面测试
ts
// tests/e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test.describe('首页', () => {
test('正确加载首页', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toContainText('VitePress')
})
test('导航链接可点击', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: '快速开始' }).click()
await expect(page).toHaveURL(/\/learning-path/)
})
test('搜索功能可用', async ({ page }) => {
await page.goto('/')
await page.getByPlaceholder('搜索文档').click()
await page.getByPlaceholder('搜索').fill('VitePress')
await expect(page.getByRole('link').first()).toBeVisible()
})
test('深色模式切换', async ({ page }) => {
await page.goto('/')
const themeBtn = page.locator('.VPSwitchAppearance')
await themeBtn.click()
await expect(page.locator('html')).toHaveClass(/dark/)
})
})教程页面测试
ts
// tests/e2e/tutorial.spec.ts
import { test, expect } from '@playwright/test'
test.describe('教程页面', () => {
test('侧边栏正确显示', async ({ page }) => {
await page.goto('/guide/what-is-vitepress')
await expect(page.locator('.VPSidebar')).toBeVisible()
})
test('文档间导航', async ({ page }) => {
await page.goto('/guide/installation')
await page.getByRole('link', { name: '下一页' }).click()
await expect(page).toHaveURL(/\/guide\//)
})
test('代码块可复制', async ({ page }) => {
await page.goto('/basics/markdown')
const codeBlock = page.locator('pre').first()
await codeBlock.hover()
const copyBtn = page.locator('.vp-copy').first()
if (await copyBtn.isVisible()) {
await copyBtn.click()
// 验证复制成功提示
await expect(page.locator('.copy-success')).toBeVisible()
}
})
test('404 页面', async ({ page }) => {
await page.goto('/non-existent-page')
await expect(page.locator('h1')).toContainText('404')
})
})移动端测试
ts
// tests/e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test'
test.describe('移动端适配', () => {
test.use(devices['iPhone 13'])
test('移动端导航菜单', async ({ page }) => {
await page.goto('/')
// 点击汉堡菜单
await page.locator('.VPNavBarHamburger').click()
await expect(page.locator('.VPFlyout')).toBeVisible()
})
test('移动端侧边栏可折叠', async ({ page }) => {
await page.goto('/guide/what-is-vitepress')
const sidebarToggle = page.locator('.VPLocalSidebarOpenButton')
if (await sidebarToggle.isVisible()) {
await sidebarToggle.click()
await expect(page.locator('.VPSidebar')).toBeVisible()
}
})
})可访问性测试
Vitest + axe-core
ts
// tests/a11y/home.a11y.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import axe from 'axe-core'
import jsdom from 'jsdom'
describe('可访问性测试', () => {
let document: Document
beforeEach(async () => {
// 模拟页面加载
const dom = new jsdom.JSDOM(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>VitePress 学习指南</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<a href="#main-content" class="skip-link">跳到内容</a>
<nav aria-label="主导航">
<a href="/">首页</a>
<a href="/guide/">教程</a>
</nav>
<main id="main-content">
<h1>欢迎</h1>
<img src="/logo.svg" alt="VitePress Logo" />
</main>
</body>
</html>
`)
document = dom.window.document
})
it('无严重可访问性问题', async () => {
const results = await axe.run(document)
expect(results.violations).toEqual([])
})
})Lighthouse CI
在 GitHub Actions 中集成 Lighthouse 可访问性检查:
yaml
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx serve docs/.vitepress/dist -l 3000 &
- run: npx wait-on http://localhost:3000
- run: npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json --output-path=./lighthouse-report.json
- run: |
SCORE=$(node -e "console.log(JSON.parse(require('fs').readFileSync('./lighthouse-report.json')).categories.accessibility.score)")
echo "Accessibility score: $SCORE"
if (( $(echo "$SCORE < 0.9" | bc -l) )); then
echo "Accessibility score below 0.9!"
exit 1
fi构建测试
验证构建无错误
ts
// tests/build.test.ts
import { describe, it, expect } from 'vitest'
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
describe('构建测试', () => {
it('构建成功且无错误', () => {
try {
execSync('npx vitepress build docs', { stdio: 'pipe' })
} catch (error: any) {
const stderr = error.stderr?.toString() || ''
expect(stderr).not.toMatch(/error/i)
}
})
it('构建产物包含关键文件', () => {
const distDir = path.resolve('docs/.vitepress/dist')
expect(fs.existsSync(path.join(distDir, 'index.html'))).toBe(true)
expect(fs.existsSync(path.join(distDir, 'assets'))).toBe(true)
})
it('没有死链接(检查内部链接)', () => {
const distDir = path.resolve('docs/.vitepress/dist')
const indexPath = path.join(distDir, 'index.html')
const content = fs.readFileSync(indexPath, 'utf-8')
// 确保没有指向不存在页面的链接
const brokenLinks = ['/non-existent', 'undefined', 'null']
for (const link of brokenLinks) {
expect(content).not.toContain(`href="${link}"`)
}
})
})测试最佳实践
| 实践 | 说明 |
|---|---|
| 测试用户行为而非实现细节 | 关注用户能做什么,而非组件内部状态 |
| Mock 外部依赖 | 隔离 VitePress 运行时、API 调用等 |
| 保持测试独立性 | 每个测试不依赖其他测试的执行顺序 |
| 使用有意义的测试名 | it('显示错误提示当网络请求失败') 比 it('works') 好 |
| 合理设置超时 | E2E 测试适当增加超时时间 |
下一步
- 学习 调试与排错 了解开发调试技巧
- 学习 CI/CD 自动化部署 在 CI 中集成测试
- 学习 错误处理与排查 常见问题解决方案