Skip to content

主题测试指南

测试是保证主题质量的重要环节。本文档将介绍如何为 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/test

Vitest 配置

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')
})

相关资源

下一步

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献