Skip to content

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-vue

Vitest 配置

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 测试适当增加超时时间

下一步

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献