Skip to content

端到端测试

使用 Pinia 进行端到端(E2E)测试涉及在真实浏览器环境中测试完整的用户工作流。本指南涵盖了从用户角度测试使用 Pinia store 的应用程序的策略。

概述

使用 Pinia 进行 E2E 测试专注于:

  • 测试完整的用户工作流
  • 验证跨页面重新加载的状态持久性
  • 测试真实的 API 交互
  • 验证跨 store 通信
  • 确保在真实场景中正确处理错误

设置

Cypress 设置

bash
npm install -D cypress
js
// cypress.config.js
import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      // 在这里实现节点事件监听器
    },
  },
  component: {
    devServer: {
      framework: 'vue',
      bundler: 'vite',
    },
  },
})

Playwright 设置

bash
npm install -D @playwright/test
js
// playwright.config.js
import { defineConfig, devices } from '@playwright/test'

export default 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',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

测试策略

1. 用户认证流程

js
// stores/auth.js
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null,
    isLoading: false,
    error: null
  }),
  
  getters: {
    isAuthenticated: (state) => !!state.token
  },
  
  actions: {
    async login(credentials) {
      this.isLoading = true
      this.error = null
      
      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(credentials)
        })
        
        if (!response.ok) {
          throw new Error('登录失败')
        }
        
        const data = await response.json()
        this.token = data.token
        this.user = data.user
        
        // 存储到 localStorage 以实现持久性
        localStorage.setItem('auth_token', data.token)
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.isLoading = false
      }
    },
    
    logout() {
      this.user = null
      this.token = null
      localStorage.removeItem('auth_token')
    }
  }
})

Cypress 测试

js
// cypress/e2e/auth.cy.js
describe('认证流程', () => {
  beforeEach(() => {
    // 每个测试前清除 localStorage
    cy.clearLocalStorage()
    cy.visit('/')
  })

  it('应该成功登录', () => {
    // 模拟 API 响应
    cy.intercept('POST', '/api/login', {
      statusCode: 200,
      body: {
        token: 'mock-jwt-token',
        user: { id: 1, name: '张三', email: 'zhangsan@example.com' }
      }
    }).as('loginRequest')

    // 导航到登录页面
    cy.get('[data-testid="login-button"]').click()
    
    // 填写登录表单
    cy.get('[data-testid="email-input"]').type('zhangsan@example.com')
    cy.get('[data-testid="password-input"]').type('password123')
    cy.get('[data-testid="submit-button"]').click()
    
    // 等待 API 调用
    cy.wait('@loginRequest')
    
    // 验证成功登录
    cy.get('[data-testid="user-name"]').should('contain', '张三')
    cy.get('[data-testid="logout-button"]').should('be.visible')
    
    // 验证 token 已存储
    cy.window().its('localStorage').invoke('getItem', 'auth_token')
      .should('equal', 'mock-jwt-token')
  })

  it('应该处理登录错误', () => {
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { message: '凭据无效' }
    }).as('loginError')

    cy.get('[data-testid="login-button"]').click()
    cy.get('[data-testid="email-input"]').type('wrong@example.com')
    cy.get('[data-testid="password-input"]').type('wrongpassword')
    cy.get('[data-testid="submit-button"]').click()
    
    cy.wait('@loginError')
    
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', '登录失败')
  })

  it('应该在页面重新加载后保持认证状态', () => {
    // 设置已认证状态
    cy.window().then((win) => {
      win.localStorage.setItem('auth_token', 'mock-jwt-token')
    })
    
    // 重新加载页面
    cy.reload()
    
    // 验证用户仍然已认证
    cy.get('[data-testid="user-name"]').should('be.visible')
    cy.get('[data-testid="logout-button"]').should('be.visible')
  })

  it('应该成功退出登录', () => {
    // 设置已认证状态
    cy.window().then((win) => {
      win.localStorage.setItem('auth_token', 'mock-jwt-token')
    })
    cy.reload()
    
    // 退出登录
    cy.get('[data-testid="logout-button"]').click()
    
    // 验证退出登录
    cy.get('[data-testid="login-button"]').should('be.visible')
    cy.window().its('localStorage').invoke('getItem', 'auth_token')
      .should('be.null')
  })
})

Playwright 测试

js
// tests/e2e/auth.spec.js
import { test, expect } from '@playwright/test'

test.describe('认证流程', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('应该成功登录', async ({ page }) => {
    // 模拟 API 响应
    await page.route('/api/login', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          token: 'mock-jwt-token',
          user: { id: 1, name: '张三', email: 'zhangsan@example.com' }
        })
      })
    })

    // 导航到登录页面
    await page.getByTestId('login-button').click()
    
    // 填写登录表单
    await page.getByTestId('email-input').fill('zhangsan@example.com')
    await page.getByTestId('password-input').fill('password123')
    await page.getByTestId('submit-button').click()
    
    // 验证成功登录
    await expect(page.getByTestId('user-name')).toContainText('张三')
    await expect(page.getByTestId('logout-button')).toBeVisible()
    
    // 验证 token 已存储
    const token = await page.evaluate(() => localStorage.getItem('auth_token'))
    expect(token).toBe('mock-jwt-token')
  })

  test('应该处理登录错误', async ({ page }) => {
    await page.route('/api/login', async (route) => {
      await route.fulfill({
        status: 401,
        contentType: 'application/json',
        body: JSON.stringify({ message: '凭据无效' })
      })
    })

    await page.getByTestId('login-button').click()
    await page.getByTestId('email-input').fill('wrong@example.com')
    await page.getByTestId('password-input').fill('wrongpassword')
    await page.getByTestId('submit-button').click()
    
    await expect(page.getByTestId('error-message')).toBeVisible()
    await expect(page.getByTestId('error-message')).toContainText('登录失败')
  })

  test('应该在页面重新加载后保持认证状态', async ({ page }) => {
    // 设置已认证状态
    await page.evaluate(() => {
      localStorage.setItem('auth_token', 'mock-jwt-token')
    })
    
    // 重新加载页面
    await page.reload()
    
    // 验证用户仍然已认证
    await expect(page.getByTestId('user-name')).toBeVisible()
    await expect(page.getByTestId('logout-button')).toBeVisible()
  })
})

2. 购物车工作流

js
// stores/cart.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    isLoading: false,
    error: null
  }),
  
  getters: {
    itemCount: (state) => state.items.reduce((total, item) => total + item.quantity, 0),
    total: (state) => state.items.reduce((total, item) => total + (item.price * item.quantity), 0)
  },
  
  actions: {
    async addItem(product, quantity = 1) {
      const authStore = useAuthStore()
      
      if (!authStore.isAuthenticated) {
        throw new Error('必须登录才能添加商品')
      }
      
      this.isLoading = true
      
      try {
        const response = await fetch('/api/cart/add', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${authStore.token}`
          },
          body: JSON.stringify({ productId: product.id, quantity })
        })
        
        if (!response.ok) {
          throw new Error('添加商品到购物车失败')
        }
        
        const existingItem = this.items.find(item => item.id === product.id)
        if (existingItem) {
          existingItem.quantity += quantity
        } else {
          this.items.push({ ...product, quantity })
        }
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.isLoading = false
      }
    },
    
    async checkout() {
      const authStore = useAuthStore()
      
      this.isLoading = true
      
      try {
        const response = await fetch('/api/cart/checkout', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${authStore.token}`
          },
          body: JSON.stringify({ items: this.items })
        })
        
        if (!response.ok) {
          throw new Error('结账失败')
        }
        
        const result = await response.json()
        this.items = [] // 成功结账后清空购物车
        return result
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.isLoading = false
      }
    }
  }
})

Cypress 测试

js
// cypress/e2e/shopping-cart.cy.js
describe('购物车工作流', () => {
  beforeEach(() => {
    cy.clearLocalStorage()
    
    // 设置已认证用户
    cy.window().then((win) => {
      win.localStorage.setItem('auth_token', 'mock-jwt-token')
    })
    
    // 模拟产品 API
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: [
        { id: 1, name: '产品 1', price: 10.99 },
        { id: 2, name: '产品 2', price: 15.99 }
      ]
    }).as('getProducts')
    
    cy.visit('/')
  })

  it('应该添加商品到购物车并结账', () => {
    // 模拟购物车 API 调用
    cy.intercept('POST', '/api/cart/add', {
      statusCode: 200,
      body: { success: true }
    }).as('addToCart')
    
    cy.intercept('POST', '/api/cart/checkout', {
      statusCode: 200,
      body: { orderId: '12345', total: 26.98 }
    }).as('checkout')
    
    // 导航到产品页面
    cy.get('[data-testid="products-link"]').click()
    cy.wait('@getProducts')
    
    // 添加第一个产品到购物车
    cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
    cy.wait('@addToCart')
    
    // 验证购物车数量更新
    cy.get('[data-testid="cart-count"]').should('contain', '1')
    
    // 添加第二个产品到购物车
    cy.get('[data-testid="product-2"] [data-testid="add-to-cart"]').click()
    cy.wait('@addToCart')
    
    // 验证购物车数量更新
    cy.get('[data-testid="cart-count"]').should('contain', '2')
    
    // 前往购物车
    cy.get('[data-testid="cart-link"]').click()
    
    // 验证购物车内容
    cy.get('[data-testid="cart-item-1"]').should('contain', '产品 1')
    cy.get('[data-testid="cart-item-2"]').should('contain', '产品 2')
    cy.get('[data-testid="cart-total"]').should('contain', '¥26.98')
    
    // 进行结账
    cy.get('[data-testid="checkout-button"]').click()
    cy.wait('@checkout')
    
    // 验证成功结账
    cy.get('[data-testid="order-confirmation"]')
      .should('be.visible')
      .and('contain', '订单 #12345')
    
    // 验证购物车为空
    cy.get('[data-testid="cart-count"]').should('contain', '0')
  })

  it('应该处理添加到购物车的错误', () => {
    cy.intercept('POST', '/api/cart/add', {
      statusCode: 500,
      body: { message: '服务器错误' }
    }).as('addToCartError')
    
    cy.get('[data-testid="products-link"]').click()
    cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
    cy.wait('@addToCartError')
    
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', '添加商品到购物车失败')
  })

  it('购物车操作应该需要认证', () => {
    // 清除认证
    cy.clearLocalStorage()
    cy.reload()
    
    cy.get('[data-testid="products-link"]').click()
    cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
    
    // 应该重定向到登录或显示错误
    cy.get('[data-testid="login-required-message"]')
      .should('be.visible')
      .and('contain', '必须登录')
  })
})

3. 跨 Store 通信

js
// cypress/e2e/cross-store-communication.cy.js
describe('跨 Store 通信', () => {
  beforeEach(() => {
    cy.clearLocalStorage()
    cy.visit('/')
  })

  it('应该更新用户偏好并在购物车中反映', () => {
    // 模拟 API
    cy.intercept('POST', '/api/login', {
      statusCode: 200,
      body: {
        token: 'mock-jwt-token',
        user: { id: 1, name: '张三', currency: 'CNY' }
      }
    }).as('login')
    
    cy.intercept('PUT', '/api/user/preferences', {
      statusCode: 200,
      body: { success: true }
    }).as('updatePreferences')
    
    // 登录
    cy.get('[data-testid="login-button"]').click()
    cy.get('[data-testid="email-input"]').type('zhangsan@example.com')
    cy.get('[data-testid="password-input"]').type('password123')
    cy.get('[data-testid="submit-button"]').click()
    cy.wait('@login')
    
    // 添加商品到购物车(应该显示人民币价格)
    cy.get('[data-testid="products-link"]').click()
    cy.get('[data-testid="product-1"] [data-testid="price"]')
      .should('contain', '¥10.99')
    
    // 更改货币偏好
    cy.get('[data-testid="user-menu"]').click()
    cy.get('[data-testid="preferences-link"]').click()
    cy.get('[data-testid="currency-select"]').select('USD')
    cy.get('[data-testid="save-preferences"]').click()
    cy.wait('@updatePreferences')
    
    // 验证购物车中价格已更新
    cy.get('[data-testid="products-link"]').click()
    cy.get('[data-testid="product-1"] [data-testid="price"]')
      .should('contain', '$9.99')
  })
})

高级测试模式

测试状态持久性

js
// cypress/e2e/state-persistence.cy.js
describe('状态持久性', () => {
  it('应该在浏览器会话间保持购物车', () => {
    // 设置已认证用户
    cy.window().then((win) => {
      win.localStorage.setItem('auth_token', 'mock-jwt-token')
    })
    
    cy.visit('/')
    
    // 添加商品到购物车
    cy.get('[data-testid="products-link"]').click()
    cy.get('[data-testid="product-1"] [data-testid="add-to-cart"]').click()
    
    // 验证购物车有商品
    cy.get('[data-testid="cart-count"]').should('contain', '1')
    
    // 通过清除除 localStorage 外的所有内容来模拟浏览器重启
    cy.reload()
    
    // 验证购物车状态已恢复
    cy.get('[data-testid="cart-count"]').should('contain', '1')
    cy.get('[data-testid="cart-link"]').click()
    cy.get('[data-testid="cart-item-1"]').should('be.visible')
  })
})

测试实时更新

js
// cypress/e2e/realtime-updates.cy.js
describe('实时更新', () => {
  it('应该实时更新库存', () => {
    cy.visit('/')
    
    // 模拟 WebSocket 连接
    cy.window().then((win) => {
      // 模拟 WebSocket 消息
      win.dispatchEvent(new CustomEvent('inventory-update', {
        detail: { productId: 1, stock: 5 }
      }))
    })
    
    // 验证库存已更新
    cy.get('[data-testid="product-1"] [data-testid="stock"]')
      .should('contain', '库存 5 件')
  })
})

性能测试

js
// cypress/e2e/performance.cy.js
describe('性能', () => {
  it('应该高效处理大型数据集', () => {
    // 模拟大型数据集
    const largeProductList = Array.from({ length: 1000 }, (_, i) => ({
      id: i + 1,
      name: `产品 ${i + 1}`,
      price: Math.random() * 100
    }))
    
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: largeProductList
    }).as('getLargeProductList')
    
    cy.visit('/')
    cy.get('[data-testid="products-link"]').click()
    cy.wait('@getLargeProductList')
    
    // 验证页面在合理时间内加载
    cy.get('[data-testid="product-list"]', { timeout: 5000 })
      .should('be.visible')
    
    // 测试搜索性能
    cy.get('[data-testid="search-input"]').type('产品 500')
    cy.get('[data-testid="product-500"]', { timeout: 2000 })
      .should('be.visible')
  })
})

最佳实践

1. 使用数据测试 ID

始终使用 data-testid 属性进行可靠的元素选择:

vue
<template>
  <button data-testid="add-to-cart" @click="addToCart">
    添加到购物车
  </button>
</template>

2. 模拟外部依赖

模拟 API 调用以确保一致的测试结果:

js
// Cypress
cy.intercept('GET', '/api/products', { fixture: 'products.json' })

// Playwright
await page.route('/api/products', route => {
  route.fulfill({ path: 'fixtures/products.json' })
})

3. 测试用户旅程

专注于完整的用户工作流而不是孤立的功能:

js
it('应该完成购买旅程', () => {
  // 1. 浏览产品
  // 2. 添加到购物车
  // 3. 登录
  // 4. 结账
  // 5. 验证订单确认
})

4. 处理异步操作

正确等待异步操作完成:

js
// Cypress
cy.get('[data-testid="loading"]').should('not.exist')
cy.get('[data-testid="content"]').should('be.visible')

// Playwright
await expect(page.getByTestId('loading')).toBeHidden()
await expect(page.getByTestId('content')).toBeVisible()

5. 测试间清理

确保测试是隔离的:

js
beforeEach(() => {
  cy.clearLocalStorage()
  cy.clearCookies()
  // 重置任何全局状态
})

测试工具

自定义命令(Cypress)

js
// cypress/support/commands.js
Cypress.Commands.add('login', (email = 'test@example.com', password = 'password') => {
  cy.intercept('POST', '/api/login', {
    statusCode: 200,
    body: {
      token: 'mock-jwt-token',
      user: { id: 1, name: '测试用户', email }
    }
  }).as('loginRequest')
  
  cy.get('[data-testid="login-button"]').click()
  cy.get('[data-testid="email-input"]').type(email)
  cy.get('[data-testid="password-input"]').type(password)
  cy.get('[data-testid="submit-button"]').click()
  cy.wait('@loginRequest')
})

Cypress.Commands.add('addToCart', (productId) => {
  cy.intercept('POST', '/api/cart/add', {
    statusCode: 200,
    body: { success: true }
  }).as('addToCart')
  
  cy.get(`[data-testid="product-${productId}"] [data-testid="add-to-cart"]`).click()
  cy.wait('@addToCart')
})

页面对象模型(Playwright)

js
// tests/pages/LoginPage.js
export class LoginPage {
  constructor(page) {
    this.page = page
    this.emailInput = page.getByTestId('email-input')
    this.passwordInput = page.getByTestId('password-input')
    this.submitButton = page.getByTestId('submit-button')
    this.errorMessage = page.getByTestId('error-message')
  }
  
  async login(email, password) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }
  
  async expectError(message) {
    await expect(this.errorMessage).toContainText(message)
  }
}

调试 E2E 测试

Cypress 调试

js
it('应该调试测试', () => {
  cy.visit('/')
  cy.debug() // 暂停执行
  cy.get('[data-testid="button"]').click()
  cy.pause() // 交互式暂停
})

Playwright 调试

js
test('应该调试测试', async ({ page }) => {
  await page.goto('/')
  await page.pause() // 交互式调试
  await page.getByTestId('button').click()
})

总结

使用 Pinia 进行 E2E 测试需要一个全面的方法,涵盖:

  • 完整的用户工作流
  • 状态持久性和恢复
  • 跨 store 通信
  • 错误处理场景
  • 性能考虑

通过遵循这些模式和最佳实践,你可以创建强大的 E2E 测试,让你从用户角度对应用程序的行为充满信心。

更多测试策略,请参阅:

Released under the MIT License.