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应用的测试与调试:

核心要点

  1. 单元测试

    • 组件测试确保UI正确渲染
    • Store测试验证状态管理逻辑
    • 路由测试保证导航功能正常
  2. 集成测试

    • API集成测试验证服务端功能
    • SSR集成测试确保渲染正确
    • 性能测试监控应用表现
  3. 端到端测试

    • 用户流程测试模拟真实使用场景
    • 响应式测试确保多设备兼容
    • 性能测试监控用户体验
  4. 调试技巧

    • 服务端调试追踪渲染过程
    • 客户端调试监控运行状态
    • DevTools集成提供可视化调试

最佳实践

  • 编写全面的测试覆盖关键功能
  • 使用适当的调试工具提高开发效率
  • 监控性能指标确保用户体验
  • 建立错误追踪机制快速定位问题

下一章预告

下一章我们将学习部署与运维,包括生产环境配置、容器化部署、监控告警以及性能优化,确保Vue SSR应用在生产环境中稳定运行。


练习作业

  1. 为一个Vue SSR组件编写完整的单元测试
  2. 实现API接口的集成测试,包括成功和错误场景
  3. 使用Playwright编写端到端测试,覆盖主要用户流程
  4. 配置调试工具,实现错误追踪和性能监控
  5. 集成Vue DevTools,添加自定义的SSR调试功能