7.1 单元测试
7.1.1 测试环境配置
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
moduleFileExtensions: ['js', 'json', 'vue'],
transform: {
'^.+\.vue$': '@vue/vue3-jest',
'^.+\.js$': 'babel-jest'
},
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1'
},
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!src/main.js',
'!src/router/index.js',
'!**/node_modules/**'
],
setupFilesAfterEnv: ['<rootDir>/tests/unit/setup.js'],
testEnvironmentOptions: {
customExportConditions: ['node', 'node-addons']
}
}
// tests/unit/setup.js
import { config } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createStore } from 'vuex'
// 全局测试配置
config.global.plugins = [
createRouter({
history: createWebHistory(),
routes: []
}),
createStore({
state: {},
mutations: {},
actions: {}
})
]
// Mock全局对象
global.fetch = require('jest-fetch-mock')
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() { return null }
disconnect() { return null }
unobserve() { return null }
}
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() { return null }
disconnect() { return null }
unobserve() { return null }
}
7.1.2 组件单元测试
// tests/unit/components/UserCard.spec.js
import { mount } from '@vue/test-utils'
import UserCard from '@/components/UserCard.vue'
describe('UserCard.vue', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: 'https://example.com/avatar.jpg'
}
it('渲染用户信息', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser
}
})
expect(wrapper.find('.user-name').text()).toBe(mockUser.name)
expect(wrapper.find('.user-email').text()).toBe(mockUser.email)
expect(wrapper.find('.user-avatar').attributes('src')).toBe(mockUser.avatar)
})
it('处理用户点击事件', async () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser
}
})
await wrapper.find('.user-card').trigger('click')
expect(wrapper.emitted()).toHaveProperty('user-click')
expect(wrapper.emitted('user-click')[0]).toEqual([mockUser])
})
it('显示默认头像当用户没有头像时', () => {
const userWithoutAvatar = { ...mockUser, avatar: null }
const wrapper = mount(UserCard, {
props: {
user: userWithoutAvatar
}
})
expect(wrapper.find('.user-avatar').attributes('src')).toBe('/default-avatar.png')
})
it('应用正确的CSS类', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
variant: 'compact'
}
})
expect(wrapper.classes()).toContain('user-card')
expect(wrapper.classes()).toContain('user-card--compact')
})
})
7.1.3 Vuex Store测试
// tests/unit/store/user.spec.js
import { createStore } from 'vuex'
import userModule from '@/store/modules/user'
import { userApi } from '@/api/user'
// Mock API
jest.mock('@/api/user')
describe('User Store Module', () => {
let store
beforeEach(() => {
store = createStore({
modules: {
user: userModule
}
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('mutations', () => {
it('SET_USER 设置用户信息', () => {
const user = { id: 1, name: 'John Doe' }
store.commit('user/SET_USER', user)
expect(store.state.user.currentUser).toEqual(user)
})
it('SET_LOADING 设置加载状态', () => {
store.commit('user/SET_LOADING', true)
expect(store.state.user.loading).toBe(true)
})
it('SET_ERROR 设置错误信息', () => {
const error = 'Network error'
store.commit('user/SET_ERROR', error)
expect(store.state.user.error).toBe(error)
})
})
describe('actions', () => {
it('fetchUser 成功获取用户信息', async () => {
const mockUser = { id: 1, name: 'John Doe' }
userApi.getUser.mockResolvedValue({ data: mockUser })
await store.dispatch('user/fetchUser', 1)
expect(userApi.getUser).toHaveBeenCalledWith(1)
expect(store.state.user.currentUser).toEqual(mockUser)
expect(store.state.user.loading).toBe(false)
expect(store.state.user.error).toBeNull()
})
it('fetchUser 处理错误情况', async () => {
const errorMessage = 'User not found'
userApi.getUser.mockRejectedValue(new Error(errorMessage))
await store.dispatch('user/fetchUser', 999)
expect(store.state.user.currentUser).toBeNull()
expect(store.state.user.loading).toBe(false)
expect(store.state.user.error).toBe(errorMessage)
})
it('updateUser 更新用户信息', async () => {
const updatedUser = { id: 1, name: 'Jane Doe' }
userApi.updateUser.mockResolvedValue({ data: updatedUser })
await store.dispatch('user/updateUser', { id: 1, data: updatedUser })
expect(userApi.updateUser).toHaveBeenCalledWith(1, updatedUser)
expect(store.state.user.currentUser).toEqual(updatedUser)
})
})
describe('getters', () => {
it('isAuthenticated 返回正确的认证状态', () => {
expect(store.getters['user/isAuthenticated']).toBe(false)
store.commit('user/SET_USER', { id: 1, name: 'John Doe' })
expect(store.getters['user/isAuthenticated']).toBe(true)
})
it('userDisplayName 返回用户显示名称', () => {
store.commit('user/SET_USER', {
id: 1,
name: 'John Doe',
email: 'john@example.com'
})
expect(store.getters['user/userDisplayName']).toBe('John Doe')
})
})
})
7.1.4 路由测试
// tests/unit/router/index.spec.js
import { createRouter, createMemoryHistory } from 'vue-router'
import { routes } from '@/router'
describe('Router', () => {
let router
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes
})
})
it('导航到首页', async () => {
await router.push('/')
expect(router.currentRoute.value.name).toBe('Home')
})
it('导航到用户详情页', async () => {
await router.push('/users/123')
expect(router.currentRoute.value.name).toBe('UserDetail')
expect(router.currentRoute.value.params.id).toBe('123')
})
it('处理未找到的路由', async () => {
await router.push('/non-existent-route')
expect(router.currentRoute.value.name).toBe('NotFound')
})
it('路由守卫正确工作', async () => {
const mockStore = {
getters: {
'user/isAuthenticated': false
}
}
// 模拟未认证用户访问受保护路由
const to = { name: 'Profile', meta: { requiresAuth: true } }
const from = { name: 'Home' }
const next = jest.fn()
// 这里需要根据实际的路由守卫逻辑进行测试
// beforeEach guard logic
if (to.meta.requiresAuth && !mockStore.getters['user/isAuthenticated']) {
next({ name: 'Login' })
} else {
next()
}
expect(next).toHaveBeenCalledWith({ name: 'Login' })
})
})
7.2 集成测试
7.2.1 API集成测试
// tests/integration/api.spec.js
import request from 'supertest'
import app from '@/server/app'
import { setupTestDB, cleanupTestDB } from './helpers/database'
describe('API Integration Tests', () => {
beforeAll(async () => {
await setupTestDB()
})
afterAll(async () => {
await cleanupTestDB()
})
describe('GET /api/users', () => {
it('返回用户列表', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toHaveProperty('data')
expect(Array.isArray(response.body.data)).toBe(true)
})
it('支持分页参数', async () => {
const response = await request(app)
.get('/api/users?page=1&limit=10')
.expect(200)
expect(response.body.data.length).toBeLessThanOrEqual(10)
expect(response.body).toHaveProperty('pagination')
})
it('支持搜索功能', async () => {
const response = await request(app)
.get('/api/users?search=john')
.expect(200)
response.body.data.forEach(user => {
expect(user.name.toLowerCase()).toContain('john')
})
})
})
describe('POST /api/users', () => {
it('创建新用户', async () => {
const newUser = {
name: 'Test User',
email: 'test@example.com',
password: 'password123'
}
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201)
expect(response.body.data).toHaveProperty('id')
expect(response.body.data.name).toBe(newUser.name)
expect(response.body.data.email).toBe(newUser.email)
expect(response.body.data).not.toHaveProperty('password')
})
it('验证必填字段', async () => {
const invalidUser = {
name: 'Test User'
// 缺少email和password
}
const response = await request(app)
.post('/api/users')
.send(invalidUser)
.expect(400)
expect(response.body).toHaveProperty('errors')
expect(response.body.errors).toContain('Email is required')
expect(response.body.errors).toContain('Password is required')
})
it('防止重复邮箱', async () => {
const user = {
name: 'Test User',
email: 'duplicate@example.com',
password: 'password123'
}
// 第一次创建
await request(app)
.post('/api/users')
.send(user)
.expect(201)
// 第二次创建相同邮箱
const response = await request(app)
.post('/api/users')
.send(user)
.expect(409)
expect(response.body.message).toContain('Email already exists')
})
})
describe('Authentication', () => {
let authToken
beforeAll(async () => {
// 创建测试用户并获取token
const user = {
name: 'Auth Test User',
email: 'auth@example.com',
password: 'password123'
}
await request(app)
.post('/api/users')
.send(user)
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: user.email,
password: user.password
})
authToken = loginResponse.body.token
})
it('保护需要认证的路由', async () => {
await request(app)
.get('/api/profile')
.expect(401)
})
it('允许认证用户访问保护路由', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
expect(response.body.data).toHaveProperty('id')
expect(response.body.data.email).toBe('auth@example.com')
})
})
})
7.2.2 SSR集成测试
// tests/integration/ssr.spec.js
import request from 'supertest'
import app from '@/server/app'
import { JSDOM } from 'jsdom'
describe('SSR Integration Tests', () => {
describe('页面渲染', () => {
it('渲染首页', async () => {
const response = await request(app)
.get('/')
.expect(200)
expect(response.text).toContain('<!DOCTYPE html>')
expect(response.text).toContain('<div id="app">')
expect(response.text).toContain('window.__INITIAL_STATE__')
})
it('渲染用户列表页', async () => {
const response = await request(app)
.get('/users')
.expect(200)
const dom = new JSDOM(response.text)
const document = dom.window.document
// 检查页面标题
expect(document.title).toContain('用户列表')
// 检查是否包含用户数据
const userElements = document.querySelectorAll('.user-card')
expect(userElements.length).toBeGreaterThan(0)
})
it('处理动态路由', async () => {
const response = await request(app)
.get('/users/123')
.expect(200)
const dom = new JSDOM(response.text)
const document = dom.window.document
// 检查用户详情是否渲染
expect(document.querySelector('.user-detail')).toBeTruthy()
// 检查初始状态是否包含用户数据
const scriptTag = document.querySelector('script[data-initial-state]')
expect(scriptTag).toBeTruthy()
const initialState = JSON.parse(scriptTag.textContent)
expect(initialState.user.currentUser).toBeTruthy()
expect(initialState.user.currentUser.id).toBe('123')
})
it('处理404页面', async () => {
const response = await request(app)
.get('/non-existent-page')
.expect(404)
expect(response.text).toContain('页面未找到')
})
it('处理服务器错误', async () => {
// 模拟服务器错误
const response = await request(app)
.get('/error-test')
.expect(500)
expect(response.text).toContain('服务器错误')
})
})
describe('SEO优化', () => {
it('包含正确的meta标签', async () => {
const response = await request(app)
.get('/users/123')
.expect(200)
const dom = new JSDOM(response.text)
const document = dom.window.document
// 检查meta标签
const description = document.querySelector('meta[name="description"]')
expect(description).toBeTruthy()
expect(description.getAttribute('content')).toContain('用户详情')
// 检查Open Graph标签
const ogTitle = document.querySelector('meta[property="og:title"]')
expect(ogTitle).toBeTruthy()
const ogDescription = document.querySelector('meta[property="og:description"]')
expect(ogDescription).toBeTruthy()
})
it('生成正确的结构化数据', async () => {
const response = await request(app)
.get('/users/123')
.expect(200)
const dom = new JSDOM(response.text)
const document = dom.window.document
const structuredData = document.querySelector('script[type="application/ld+json"]')
expect(structuredData).toBeTruthy()
const data = JSON.parse(structuredData.textContent)
expect(data['@type']).toBe('Person')
expect(data.name).toBeTruthy()
})
})
describe('性能测试', () => {
it('页面渲染时间在合理范围内', async () => {
const startTime = Date.now()
await request(app)
.get('/')
.expect(200)
const renderTime = Date.now() - startTime
expect(renderTime).toBeLessThan(1000) // 1秒内
})
it('缓存正确工作', async () => {
// 第一次请求
const response1 = await request(app)
.get('/users')
.expect(200)
// 第二次请求(应该从缓存返回)
const startTime = Date.now()
const response2 = await request(app)
.get('/users')
.expect(200)
const cacheTime = Date.now() - startTime
expect(cacheTime).toBeLessThan(100) // 缓存响应应该很快
expect(response1.text).toBe(response2.text)
})
})
})
7.3 端到端测试
7.3.1 Playwright配置
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test')
module.exports = defineConfig({
testDir: './tests/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',
screenshot: 'only-on-failure',
video: 'retain-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'] }
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] }
}
],
webServer: {
command: 'npm run serve',
port: 3000,
reuseExistingServer: !process.env.CI
}
})
7.3.2 用户流程测试
// tests/e2e/user-flow.spec.js
const { test, expect } = require('@playwright/test')
test.describe('用户流程测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('用户注册流程', async ({ page }) => {
// 点击注册按钮
await page.click('text=注册')
// 填写注册表单
await page.fill('[data-testid="name-input"]', 'Test User')
await page.fill('[data-testid="email-input"]', 'test@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
await page.fill('[data-testid="confirm-password-input"]', 'password123')
// 提交表单
await page.click('[data-testid="submit-button"]')
// 验证注册成功
await expect(page.locator('text=注册成功')).toBeVisible()
await expect(page).toHaveURL('/dashboard')
})
test('用户登录流程', async ({ page }) => {
// 点击登录按钮
await page.click('text=登录')
// 填写登录表单
await page.fill('[data-testid="email-input"]', 'test@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
// 提交表单
await page.click('[data-testid="login-button"]')
// 验证登录成功
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
await expect(page).toHaveURL('/dashboard')
})
test('用户个人资料编辑', async ({ page, context }) => {
// 先登录
await page.goto('/login')
await page.fill('[data-testid="email-input"]', 'test@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
await page.click('[data-testid="login-button"]')
// 进入个人资料页面
await page.click('[data-testid="user-menu"]')
await page.click('text=个人资料')
// 编辑个人信息
await page.fill('[data-testid="name-input"]', 'Updated Name')
await page.fill('[data-testid="bio-input"]', 'This is my bio')
// 上传头像
const fileInput = page.locator('[data-testid="avatar-input"]')
await fileInput.setInputFiles('tests/fixtures/avatar.jpg')
// 保存更改
await page.click('[data-testid="save-button"]')
// 验证更新成功
await expect(page.locator('text=保存成功')).toBeVisible()
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Updated Name')
})
test('搜索功能', async ({ page }) => {
// 进入用户列表页面
await page.goto('/users')
// 使用搜索功能
await page.fill('[data-testid="search-input"]', 'john')
await page.press('[data-testid="search-input"]', 'Enter')
// 验证搜索结果
await expect(page.locator('[data-testid="user-card"]')).toHaveCount(1)
await expect(page.locator('[data-testid="user-card"]').first()).toContainText('john')
// 清空搜索
await page.fill('[data-testid="search-input"]', '')
await page.press('[data-testid="search-input"]', 'Enter')
// 验证显示所有用户
await expect(page.locator('[data-testid="user-card"]')).toHaveCountGreaterThan(1)
})
test('分页功能', async ({ page }) => {
await page.goto('/users')
// 检查分页控件
await expect(page.locator('[data-testid="pagination"]')).toBeVisible()
// 点击下一页
await page.click('[data-testid="next-page"]')
// 验证URL变化
await expect(page).toHaveURL(/page=2/)
// 验证页面内容更新
await expect(page.locator('[data-testid="current-page"]')).toHaveText('2')
})
})
7.3.3 响应式测试
// tests/e2e/responsive.spec.js
const { test, expect } = require('@playwright/test')
test.describe('响应式设计测试', () => {
const viewports = [
{ name: 'Mobile', width: 375, height: 667 },
{ name: 'Tablet', width: 768, height: 1024 },
{ name: 'Desktop', width: 1920, height: 1080 }
]
viewports.forEach(({ name, width, height }) => {
test(`${name} 视口测试`, async ({ page }) => {
await page.setViewportSize({ width, height })
await page.goto('/')
if (name === 'Mobile') {
// 移动端测试
await expect(page.locator('[data-testid="mobile-menu-button"]')).toBeVisible()
await expect(page.locator('[data-testid="desktop-nav"]')).toBeHidden()
// 测试移动菜单
await page.click('[data-testid="mobile-menu-button"]')
await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible()
} else {
// 桌面端测试
await expect(page.locator('[data-testid="mobile-menu-button"]')).toBeHidden()
await expect(page.locator('[data-testid="desktop-nav"]')).toBeVisible()
}
// 测试内容布局
const container = page.locator('[data-testid="main-container"]')
const boundingBox = await container.boundingBox()
if (name === 'Mobile') {
expect(boundingBox.width).toBeLessThanOrEqual(width)
} else {
expect(boundingBox.width).toBeGreaterThan(width * 0.8)
}
})
})
test('触摸手势测试', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/gallery')
// 测试滑动手势
const gallery = page.locator('[data-testid="image-gallery"]')
// 向左滑动
await gallery.hover()
await page.mouse.down()
await page.mouse.move(100, 0)
await page.mouse.up()
// 验证图片切换
await expect(page.locator('[data-testid="current-image"]')).toHaveAttribute('data-index', '1')
})
})
7.3.4 性能测试
// tests/e2e/performance.spec.js
const { test, expect } = require('@playwright/test')
test.describe('性能测试', () => {
test('页面加载性能', async ({ page }) => {
// 开始性能监控
await page.goto('/', { waitUntil: 'networkidle' })
// 获取性能指标
const performanceMetrics = await page.evaluate(() => {
const navigation = performance.getEntriesByType('navigation')[0]
const paint = performance.getEntriesByType('paint')
return {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime,
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime
}
})
// 验证性能指标
expect(performanceMetrics.domContentLoaded).toBeLessThan(2000) // 2秒内
expect(performanceMetrics.loadComplete).toBeLessThan(3000) // 3秒内
expect(performanceMetrics.firstPaint).toBeLessThan(1500) // 1.5秒内
expect(performanceMetrics.firstContentfulPaint).toBeLessThan(2000) // 2秒内
})
test('资源加载优化', async ({ page }) => {
const responses = []
// 监听网络请求
page.on('response', response => {
responses.push({
url: response.url(),
status: response.status(),
size: response.headers()['content-length'],
type: response.headers()['content-type']
})
})
await page.goto('/')
// 验证资源压缩
const jsFiles = responses.filter(r => r.type?.includes('javascript'))
const cssFiles = responses.filter(r => r.type?.includes('css'))
jsFiles.forEach(file => {
expect(parseInt(file.size || '0')).toBeLessThan(500000) // JS文件小于500KB
})
cssFiles.forEach(file => {
expect(parseInt(file.size || '0')).toBeLessThan(100000) // CSS文件小于100KB
})
})
test('内存使用监控', async ({ page }) => {
await page.goto('/')
// 获取初始内存使用
const initialMemory = await page.evaluate(() => {
return performance.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize
} : null
})
if (initialMemory) {
// 执行一些操作
await page.click('[data-testid="load-more-button"]')
await page.waitForTimeout(2000)
// 获取操作后内存使用
const finalMemory = await page.evaluate(() => {
return {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize
}
})
// 验证内存增长在合理范围内
const memoryIncrease = finalMemory.usedJSHeapSize - initialMemory.usedJSHeapSize
expect(memoryIncrease).toBeLessThan(10000000) // 内存增长小于10MB
}
})
})
7.4 调试技巧
7.4.1 服务端调试
// server/debug.js
const debug = require('debug')
const util = require('util')
// 创建不同类型的调试器
const debugRender = debug('ssr:render')
const debugCache = debug('ssr:cache')
const debugError = debug('ssr:error')
const debugPerf = debug('ssr:performance')
class SSRDebugger {
constructor() {
this.renderTimes = new Map()
this.cacheStats = {
hits: 0,
misses: 0
}
}
// 渲染调试
logRenderStart(url, context) {
const startTime = Date.now()
this.renderTimes.set(url, startTime)
debugRender('开始渲染: %s', url)
debugRender('上下文: %O', {
userAgent: context.userAgent,
cookies: Object.keys(context.cookies || {}),
query: context.query
})
}
logRenderEnd(url, success = true) {
const startTime = this.renderTimes.get(url)
if (startTime) {
const duration = Date.now() - startTime
debugRender('渲染完成: %s (%dms) %s', url, duration, success ? '✓' : '✗')
this.renderTimes.delete(url)
if (duration > 1000) {
debugPerf('慢渲染警告: %s 耗时 %dms', url, duration)
}
}
}
// 缓存调试
logCacheHit(key) {
this.cacheStats.hits++
debugCache('缓存命中: %s (命中率: %d%%)', key, this.getCacheHitRate())
}
logCacheMiss(key) {
this.cacheStats.misses++
debugCache('缓存未命中: %s (命中率: %d%%)', key, this.getCacheHitRate())
}
getCacheHitRate() {
const total = this.cacheStats.hits + this.cacheStats.misses
return total > 0 ? Math.round((this.cacheStats.hits / total) * 100) : 0
}
// 错误调试
logError(error, context = {}) {
debugError('渲染错误: %s', error.message)
debugError('错误堆栈: %s', error.stack)
debugError('上下文: %O', context)
// 详细的错误信息
if (error.componentStack) {
debugError('组件堆栈: %s', error.componentStack)
}
}
// 性能分析
analyzePerformance() {
const memUsage = process.memoryUsage()
debugPerf('内存使用: %O', {
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB',
external: Math.round(memUsage.external / 1024 / 1024) + 'MB',
rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB'
})
debugPerf('缓存统计: %O', this.cacheStats)
}
// 组件渲染追踪
traceComponentRender(componentName, props) {
debugRender('渲染组件: %s', componentName)
debugRender('组件属性: %O', props)
}
// 状态变化追踪
traceStateChange(storeName, mutation, payload) {
debugRender('状态变化: %s/%s', storeName, mutation)
debugRender('载荷: %O', payload)
}
}
// 创建全局调试器实例
const ssrDebugger = new SSRDebugger()
// 定期输出性能分析
setInterval(() => {
ssrDebugger.analyzePerformance()
}, 30000)
module.exports = ssrDebugger
7.4.2 客户端调试
// utils/client-debug.js
class ClientDebugger {
constructor() {
this.enabled = process.env.NODE_ENV === 'development'
this.logs = []
this.setupGlobalErrorHandler()
this.setupPerformanceObserver()
}
// 设置全局错误处理
setupGlobalErrorHandler() {
if (!this.enabled) return
window.addEventListener('error', (event) => {
this.logError('JavaScript错误', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
})
})
window.addEventListener('unhandledrejection', (event) => {
this.logError('未处理的Promise拒绝', {
reason: event.reason,
promise: event.promise
})
})
}
// 设置性能观察器
setupPerformanceObserver() {
if (!this.enabled || !window.PerformanceObserver) return
// 观察导航性能
const navObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
this.logPerformance('导航性能', {
name: entry.name,
duration: entry.duration,
domContentLoaded: entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart,
loadComplete: entry.loadEventEnd - entry.loadEventStart
})
})
})
navObserver.observe({ entryTypes: ['navigation'] })
// 观察资源加载性能
const resourceObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 1000) { // 只记录加载时间超过1秒的资源
this.logPerformance('慢资源加载', {
name: entry.name,
duration: entry.duration,
size: entry.transferSize,
type: entry.initiatorType
})
}
})
})
resourceObserver.observe({ entryTypes: ['resource'] })
// 观察长任务
const longTaskObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
this.logPerformance('长任务检测', {
duration: entry.duration,
startTime: entry.startTime,
attribution: entry.attribution
})
})
})
longTaskObserver.observe({ entryTypes: ['longtask'] })
}
// 记录错误
logError(type, details) {
const log = {
type: 'error',
category: type,
details,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent
}
this.logs.push(log)
console.error(`[SSR Debug] ${type}:`, details)
// 发送到错误追踪服务
this.sendToErrorTracking(log)
}
// 记录性能信息
logPerformance(type, metrics) {
const log = {
type: 'performance',
category: type,
metrics,
timestamp: new Date().toISOString(),
url: window.location.href
}
this.logs.push(log)
console.log(`[SSR Debug] ${type}:`, metrics)
}
// 记录组件渲染
logComponentRender(componentName, renderTime, props) {
if (!this.enabled) return
const log = {
type: 'component',
category: 'render',
componentName,
renderTime,
props: this.sanitizeProps(props),
timestamp: new Date().toISOString()
}
this.logs.push(log)
if (renderTime > 16) { // 超过一帧的时间
console.warn(`[SSR Debug] 慢组件渲染: ${componentName} (${renderTime}ms)`)
}
}
// 记录状态变化
logStateChange(action, payload, state) {
if (!this.enabled) return
const log = {
type: 'state',
category: 'mutation',
action,
payload: this.sanitizeData(payload),
state: this.sanitizeData(state),
timestamp: new Date().toISOString()
}
this.logs.push(log)
console.log(`[SSR Debug] 状态变化: ${action}`, { payload, state })
}
// 记录路由变化
logRouteChange(from, to, duration) {
if (!this.enabled) return
const log = {
type: 'route',
category: 'navigation',
from: from.path,
to: to.path,
duration,
timestamp: new Date().toISOString()
}
this.logs.push(log)
console.log(`[SSR Debug] 路由变化: ${from.path} -> ${to.path} (${duration}ms)`)
}
// 清理敏感数据
sanitizeData(data) {
if (!data || typeof data !== 'object') return data
const sanitized = { ...data }
const sensitiveKeys = ['password', 'token', 'secret', 'key']
Object.keys(sanitized).forEach(key => {
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
sanitized[key] = '[REDACTED]'
}
})
return sanitized
}
// 清理组件属性
sanitizeProps(props) {
if (!props || typeof props !== 'object') return props
const sanitized = {}
Object.keys(props).forEach(key => {
const value = props[key]
if (typeof value === 'function') {
sanitized[key] = '[Function]'
} else if (value && typeof value === 'object') {
sanitized[key] = '[Object]'
} else {
sanitized[key] = value
}
})
return sanitized
}
// 发送到错误追踪服务
async sendToErrorTracking(log) {
try {
if (window.Sentry) {
window.Sentry.captureException(new Error(log.category), {
extra: log.details,
tags: {
type: log.type,
category: log.category
}
})
}
// 或发送到自定义API
if (process.env.VUE_APP_ERROR_TRACKING_API) {
await fetch(process.env.VUE_APP_ERROR_TRACKING_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(log)
})
}
} catch (error) {
console.error('发送错误日志失败:', error)
}
}
// 获取调试报告
getDebugReport() {
return {
logs: this.logs,
performance: this.getPerformanceMetrics(),
environment: this.getEnvironmentInfo()
}
}
// 获取性能指标
getPerformanceMetrics() {
if (!window.performance) return null
const navigation = performance.getEntriesByType('navigation')[0]
const paint = performance.getEntriesByType('paint')
return {
navigation: navigation ? {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
firstByte: navigation.responseStart - navigation.requestStart
} : null,
paint: paint.reduce((acc, entry) => {
acc[entry.name] = entry.startTime
return acc
}, {}),
memory: performance.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
} : null
}
}
// 获取环境信息
getEnvironmentInfo() {
return {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screen: {
width: screen.width,
height: screen.height,
colorDepth: screen.colorDepth
},
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
url: window.location.href,
referrer: document.referrer
}
}
// 导出日志
exportLogs() {
const report = this.getDebugReport()
const blob = new Blob([JSON.stringify(report, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `ssr-debug-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
// 创建全局调试器实例
const clientDebugger = new ClientDebugger()
// 在开发环境下暴露到全局
if (process.env.NODE_ENV === 'development') {
window.__SSR_DEBUGGER__ = clientDebugger
}
export default clientDebugger
7.4.3 Vue DevTools集成
// plugins/devtools.js
import { setupDevtoolsPlugin } from '@vue/devtools-api'
export function setupSSRDevtools(app) {
if (process.env.NODE_ENV === 'development') {
setupDevtoolsPlugin({
id: 'vue-ssr-devtools',
label: 'Vue SSR',
packageName: 'vue-ssr-devtools',
homepage: 'https://github.com/your-repo/vue-ssr-devtools',
componentStateTypes: [
'SSR State',
'Hydration State',
'Cache State'
],
app
}, (api) => {
// 添加SSR相关的检查器
api.addInspector({
id: 'ssr-inspector',
label: 'SSR Inspector',
icon: 'storage'
})
// 添加时间线事件
api.addTimelineLayer({
id: 'ssr-timeline',
label: 'SSR Events',
color: 0xFF984F
})
// 监听SSR事件
app.config.globalProperties.$ssrDebugger = {
logHydration: (componentName, duration) => {
api.addTimelineEvent({
layerId: 'ssr-timeline',
event: {
time: Date.now(),
data: {
component: componentName,
duration,
type: 'hydration'
},
title: `Hydrated: ${componentName}`,
subtitle: `${duration}ms`
}
})
},
logCacheHit: (key, type) => {
api.addTimelineEvent({
layerId: 'ssr-timeline',
event: {
time: Date.now(),
data: {
key,
type,
hit: true
},
title: `Cache Hit: ${type}`,
subtitle: key
}
})
},
logCacheMiss: (key, type) => {
api.addTimelineEvent({
layerId: 'ssr-timeline',
event: {
time: Date.now(),
data: {
key,
type,
hit: false
},
title: `Cache Miss: ${type}`,
subtitle: key
}
})
}
}
// 检查器树节点
api.on.getInspectorTree((payload) => {
if (payload.inspectorId === 'ssr-inspector') {
payload.rootNodes = [
{
id: 'ssr-state',
label: 'SSR State',
children: [
{
id: 'initial-state',
label: 'Initial State'
},
{
id: 'hydration-state',
label: 'Hydration State'
}
]
},
{
id: 'cache-state',
label: 'Cache State',
children: [
{
id: 'component-cache',
label: 'Component Cache'
},
{
id: 'page-cache',
label: 'Page Cache'
}
]
}
]
}
})
// 检查器状态
api.on.getInspectorState((payload) => {
if (payload.inspectorId === 'ssr-inspector') {
if (payload.nodeId === 'initial-state') {
payload.state = {
'Initial State': [
{
key: 'window.__INITIAL_STATE__',
value: window.__INITIAL_STATE__ || null,
editable: false
}
]
}
}
// 其他节点的状态...
}
})
})
}
}
本章小结
在本章中,我们全面学习了Vue SSR应用的测试与调试:
核心要点
单元测试
- 组件测试确保UI正确渲染
- Store测试验证状态管理逻辑
- 路由测试保证导航功能正常
集成测试
- API集成测试验证服务端功能
- SSR集成测试确保渲染正确
- 性能测试监控应用表现
端到端测试
- 用户流程测试模拟真实使用场景
- 响应式测试确保多设备兼容
- 性能测试监控用户体验
调试技巧
- 服务端调试追踪渲染过程
- 客户端调试监控运行状态
- DevTools集成提供可视化调试
最佳实践
- 编写全面的测试覆盖关键功能
- 使用适当的调试工具提高开发效率
- 监控性能指标确保用户体验
- 建立错误追踪机制快速定位问题
下一章预告
下一章我们将学习部署与运维,包括生产环境配置、容器化部署、监控告警以及性能优化,确保Vue SSR应用在生产环境中稳定运行。
练习作业
- 为一个Vue SSR组件编写完整的单元测试
- 实现API接口的集成测试,包括成功和错误场景
- 使用Playwright编写端到端测试,覆盖主要用户流程
- 配置调试工具,实现错误追踪和性能监控
- 集成Vue DevTools,添加自定义的SSR调试功能