发布于

Cypress 端到端测试指南:现代 Web 应用的自动化测试解决方案

作者

Cypress 端到端测试指南:现代 Web 应用的自动化测试解决方案

Cypress 是一个现代化的端到端测试框架,提供了直观的测试编写体验和强大的调试能力。本文将深入探讨 Cypress 的核心特性、测试策略和最佳实践。

Cypress 基础配置

项目初始化

// cypress.config.js - Cypress 配置文件
const { defineConfig } = require('cypress')

module.exports = defineConfig({
  e2e: {
    // 基础 URL
    baseUrl: 'http://localhost:3000',

    // 测试文件模式
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',

    // 支持文件
    supportFile: 'cypress/support/e2e.js',

    // 视窗大小
    viewportWidth: 1280,
    viewportHeight: 720,

    // 超时设置
    defaultCommandTimeout: 10000,
    requestTimeout: 10000,
    responseTimeout: 10000,
    pageLoadTimeout: 30000,

    // 重试配置
    retries: {
      runMode: 2,
      openMode: 0,
    },

    // 视频录制
    video: true,
    videosFolder: 'cypress/videos',

    // 截图配置
    screenshotOnRunFailure: true,
    screenshotsFolder: 'cypress/screenshots',

    // 环境变量
    env: {
      apiUrl: 'http://localhost:8080/api',
      adminEmail: 'admin@example.com',
      adminPassword: 'admin123',
    },

    // 设置钩子
    setupNodeEvents(on, config) {
      // 任务注册
      on('task', {
        log(message) {
          console.log(message)
          return null
        },

        // 数据库操作
        seedDatabase() {
          // 数据库种子数据逻辑
          return null
        },

        clearDatabase() {
          // 清理数据库逻辑
          return null
        },
      })

      // 文件预处理器
      on(
        'file:preprocessor',
        require('@cypress/webpack-preprocessor')({
          webpackOptions: {
            resolve: {
              extensions: ['.ts', '.js'],
              alias: {
                '@': path.resolve(__dirname, 'src'),
              },
            },
            module: {
              rules: [
                {
                  test: /\.ts$/,
                  exclude: /node_modules/,
                  use: 'ts-loader',
                },
              ],
            },
          },
        })
      )

      return config
    },
  },

  component: {
    devServer: {
      framework: 'react',
      bundler: 'webpack',
    },
    specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
    supportFile: 'cypress/support/component.js',
  },
})

// cypress/support/e2e.js - 支持文件
import './commands'

// 全局配置
Cypress.on('uncaught:exception', (err, runnable) => {
  // 忽略特定错误
  if (err.message.includes('ResizeObserver loop limit exceeded')) {
    return false
  }
  return true
})

// 全局钩子
beforeEach(() => {
  // 每个测试前的设置
  cy.log('开始新的测试')
})

afterEach(() => {
  // 每个测试后的清理
  cy.log('测试完成')
})

自定义命令

// cypress/support/commands.js - 自定义命令
// 登录命令
Cypress.Commands.add('login', (email, password) => {
  cy.session([email, password], () => {
    cy.visit('/login')
    cy.get('[data-cy=email-input]').type(email)
    cy.get('[data-cy=password-input]').type(password)
    cy.get('[data-cy=login-button]').click()
    cy.url().should('not.include', '/login')
    cy.get('[data-cy=user-menu]').should('be.visible')
  })
})

// API 登录
Cypress.Commands.add('loginByAPI', (email, password) => {
  cy.request({
    method: 'POST',
    url: `${Cypress.env('apiUrl')}/auth/login`,
    body: { email, password }
  }).then((response) => {
    window.localStorage.setItem('authToken', response.body.token)
    window.localStorage.setItem('user', JSON.stringify(response.body.user))
  })
})

// 数据库种子数据
Cypress.Commands.add('seedDatabase', () => {
  cy.task('seedDatabase')
})

// 清理数据库
Cypress.Commands.add('clearDatabase', () => {
  cy.task('clearDatabase')
})

// 等待 API 请求
Cypress.Commands.add('waitForAPI', (alias, timeout = 10000) => {
  cy.wait(alias, { timeout })
})

// 拖拽命令
Cypress.Commands.add('dragAndDrop', (sourceSelector, targetSelector) => {
  cy.get(sourceSelector).trigger('mousedown', { button: 0 })
  cy.get(targetSelector).trigger('mousemove').trigger('mouseup')
})

// 文件上传
Cypress.Commands.add('uploadFile', (selector, fileName, fileType = 'text/plain') => {
  cy.get(selector).selectFile({
    contents: Cypress.Buffer.from('file contents'),
    fileName,
    mimeType: fileType
  })
})

// 表格操作
Cypress.Commands.add('getTableCell', (row, column) => {
  return cy.get(`[data-cy=data-table] tbody tr:nth-child(${row}) td:nth-child(${column})`)
})

// 模态框操作
Cypress.Commands.add('openModal', (triggerSelector) => {
  cy.get(triggerSelector).click()
  cy.get('[data-cy=modal]').should('be.visible')
})

Cypress.Commands.add('closeModal', () => {
  cy.get('[data-cy=modal-close]').click()
  cy.get('[data-cy=modal]').should('not.exist')
})

// 通知检查
Cypress.Commands.add('checkNotification', (message, type = 'success') => {
  cy.get(`[data-cy=notification][data-type=${type}]`)
    .should('be.visible')
    .and('contain.text', message)
})

// 表单填写
Cypress.Commands.add('fillForm', (formData) => {
  Object.entries(formData).forEach(([field, value]) => {
    if (Array.isArray(value)) {
      // 多选框
      value.forEach(option => {
        cy.get(`[data-cy=${field}] [value="${option}"]`).check()
      })
    } else if (typeof value === 'boolean') {
      // 复选框
      if (value) {
        cy.get(`[data-cy=${field}]`).check()
      } else {
        cy.get(`[data-cy=${field}]`).uncheck()
      }
    } else {
      // 普通输入框
      cy.get(`[data-cy=${field}]`).clear().type(value)
    }
  })
})

// 类型声明 (TypeScript)
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>
      loginByAPI(email: string, password: string): Chainable<void>
      seedDatabase(): Chainable<void>
      clearDatabase(): Chainable<void>
      waitForAPI(alias: string, timeout?: number): Chainable<void>
      dragAndDrop(sourceSelector: string, targetSelector: string): Chainable<void>
      uploadFile(selector: string, fileName: string, fileType?: string): Chainable<void>
      getTableCell(row: number, column: number): Chainable<JQuery<HTMLElement>>
      openModal(triggerSelector: string): Chainable<void>
      closeModal(): Chainable<void>
      checkNotification(message: string, type?: string): Chainable<void>
      fillForm(formData: Record<string, any>): Chainable<void>
    }
  }
}

基础测试编写

用户界面测试

// cypress/e2e/auth.cy.js - 认证测试
describe('用户认证', () => {
  beforeEach(() => {
    cy.clearDatabase()
    cy.seedDatabase()
  })

  describe('登录功能', () => {
    beforeEach(() => {
      cy.visit('/login')
    })

    it('应该成功登录有效用户', () => {
      // 输入凭据
      cy.get('[data-cy=email-input]')
        .type('user@example.com')
        .should('have.value', 'user@example.com')

      cy.get('[data-cy=password-input]').type('password123').should('have.value', 'password123')

      // 提交表单
      cy.get('[data-cy=login-button]').click()

      // 验证登录成功
      cy.url().should('eq', Cypress.config().baseUrl + '/dashboard')
      cy.get('[data-cy=user-menu]').should('be.visible')
      cy.get('[data-cy=welcome-message]').should('contain.text', '欢迎回来')
    })

    it('应该显示无效凭据错误', () => {
      cy.get('[data-cy=email-input]').type('invalid@example.com')
      cy.get('[data-cy=password-input]').type('wrongpassword')
      cy.get('[data-cy=login-button]').click()

      cy.get('[data-cy=error-message]').should('be.visible').and('contain.text', '邮箱或密码错误')

      cy.url().should('include', '/login')
    })

    it('应该验证必填字段', () => {
      cy.get('[data-cy=login-button]').click()

      cy.get('[data-cy=email-error]').should('be.visible').and('contain.text', '请输入邮箱地址')

      cy.get('[data-cy=password-error]').should('be.visible').and('contain.text', '请输入密码')
    })

    it('应该支持记住我功能', () => {
      cy.get('[data-cy=email-input]').type('user@example.com')
      cy.get('[data-cy=password-input]').type('password123')
      cy.get('[data-cy=remember-me]').check()
      cy.get('[data-cy=login-button]').click()

      // 验证登录成功后刷新页面仍保持登录状态
      cy.reload()
      cy.get('[data-cy=user-menu]').should('be.visible')
    })
  })

  describe('注册功能', () => {
    beforeEach(() => {
      cy.visit('/register')
    })

    it('应该成功注册新用户', () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
        password: 'password123',
        confirmPassword: 'password123',
      }

      cy.fillForm(userData)
      cy.get('[data-cy=terms-checkbox]').check()
      cy.get('[data-cy=register-button]').click()

      cy.checkNotification('注册成功,请查收验证邮件')
      cy.url().should('include', '/verify-email')
    })

    it('应该验证密码确认', () => {
      cy.get('[data-cy=password]').type('password123')
      cy.get('[data-cy=confirmPassword]').type('differentpassword')
      cy.get('[data-cy=register-button]').click()

      cy.get('[data-cy=confirm-password-error]').should('contain.text', '密码确认不匹配')
    })
  })

  describe('密码重置', () => {
    it('应该发送密码重置邮件', () => {
      cy.visit('/forgot-password')

      cy.get('[data-cy=email-input]').type('user@example.com')
      cy.get('[data-cy=reset-button]').click()

      cy.checkNotification('密码重置邮件已发送')
    })
  })
})

// cypress/e2e/dashboard.cy.js - 仪表板测试
describe('用户仪表板', () => {
  beforeEach(() => {
    cy.clearDatabase()
    cy.seedDatabase()
    cy.loginByAPI('user@example.com', 'password123')
    cy.visit('/dashboard')
  })

  it('应该显示用户统计信息', () => {
    cy.get('[data-cy=stats-card]').should('have.length', 4)

    cy.get('[data-cy=total-orders]')
      .should('be.visible')
      .find('[data-cy=stat-value]')
      .should('not.be.empty')

    cy.get('[data-cy=total-revenue]')
      .should('be.visible')
      .find('[data-cy=stat-value]')
      .should('match', /^\$[\d,]+\.\d{2}$/)
  })

  it('应该显示最近活动', () => {
    cy.get('[data-cy=recent-activities]').should('be.visible')
    cy.get('[data-cy=activity-item]').should('have.length.at.least', 1)

    cy.get('[data-cy=activity-item]')
      .first()
      .within(() => {
        cy.get('[data-cy=activity-title]').should('not.be.empty')
        cy.get('[data-cy=activity-time]').should('not.be.empty')
      })
  })

  it('应该支持图表交互', () => {
    cy.get('[data-cy=revenue-chart]').should('be.visible')

    // 悬停显示工具提示
    cy.get('[data-cy=revenue-chart] .recharts-bar').first().trigger('mouseover')
    cy.get('.recharts-tooltip').should('be.visible')

    // 点击图例切换数据系列
    cy.get('[data-cy=chart-legend] .recharts-legend-item').first().click()
    cy.get('[data-cy=revenue-chart] .recharts-bar').should('not.be.visible')
  })
})

表单和交互测试

// cypress/e2e/product-management.cy.js - 产品管理测试
describe('产品管理', () => {
  beforeEach(() => {
    cy.clearDatabase()
    cy.seedDatabase()
    cy.loginByAPI(Cypress.env('adminEmail'), Cypress.env('adminPassword'))
  })

  describe('产品列表', () => {
    beforeEach(() => {
      cy.visit('/admin/products')
    })

    it('应该显示产品列表', () => {
      cy.get('[data-cy=products-table]').should('be.visible')
      cy.get('[data-cy=product-row]').should('have.length.at.least', 1)

      // 检查表头
      cy.get('[data-cy=products-table] thead th')
        .should('contain.text', '产品名称')
        .and('contain.text', '价格')
        .and('contain.text', '库存')
        .and('contain.text', '状态')
    })

    it('应该支持搜索功能', () => {
      cy.get('[data-cy=search-input]').type('iPhone')
      cy.get('[data-cy=search-button]').click()

      cy.get('[data-cy=product-row]').each(($row) => {
        cy.wrap($row).should('contain.text', 'iPhone')
      })
    })

    it('应该支持筛选功能', () => {
      cy.get('[data-cy=category-filter]').select('电子产品')
      cy.get('[data-cy=status-filter]').select('有库存')
      cy.get('[data-cy=apply-filters]').click()

      cy.get('[data-cy=product-row]').should('have.length.at.least', 1)
      cy.get('[data-cy=no-results]').should('not.exist')
    })

    it('应该支持排序功能', () => {
      // 按价格排序
      cy.get('[data-cy=price-header]').click()

      cy.get('[data-cy=product-row] [data-cy=product-price]').then(($prices) => {
        const prices = Array.from($prices).map((el) => parseFloat(el.textContent.replace('$', '')))
        const sortedPrices = [...prices].sort((a, b) => a - b)
        expect(prices).to.deep.equal(sortedPrices)
      })
    })

    it('应该支持分页', () => {
      cy.get('[data-cy=pagination]').should('be.visible')

      // 检查当前页
      cy.get('[data-cy=current-page]').should('contain.text', '1')

      // 跳转到下一页
      cy.get('[data-cy=next-page]').click()
      cy.get('[data-cy=current-page]').should('contain.text', '2')

      // 跳转到上一页
      cy.get('[data-cy=prev-page]').click()
      cy.get('[data-cy=current-page]').should('contain.text', '1')
    })
  })

  describe('产品创建', () => {
    beforeEach(() => {
      cy.visit('/admin/products/new')
    })

    it('应该成功创建新产品', () => {
      const productData = {
        name: 'Test Product',
        description: 'This is a test product',
        price: '99.99',
        category: '电子产品',
        sku: 'TEST-001',
        stock: '100',
      }

      cy.fillForm(productData)

      // 上传产品图片
      cy.uploadFile('[data-cy=product-image]', 'product.jpg', 'image/jpeg')

      // 设置产品标签
      cy.get('[data-cy=tags-input]').type('新品{enter}热销{enter}')

      // 保存产品
      cy.get('[data-cy=save-product]').click()

      cy.checkNotification('产品创建成功')
      cy.url().should('match', /\/admin\/products\/\d+/)
    })

    it('应该验证必填字段', () => {
      cy.get('[data-cy=save-product]').click()

      cy.get('[data-cy=name-error]').should('contain.text', '请输入产品名称')
      cy.get('[data-cy=price-error]').should('contain.text', '请输入产品价格')
      cy.get('[data-cy=category-error]').should('contain.text', '请选择产品分类')
    })

    it('应该验证价格格式', () => {
      cy.get('[data-cy=price]').type('invalid-price')
      cy.get('[data-cy=save-product]').click()

      cy.get('[data-cy=price-error]').should('contain.text', '请输入有效的价格')
    })
  })

  describe('产品编辑', () => {
    it('应该成功编辑产品', () => {
      cy.visit('/admin/products')

      // 点击编辑按钮
      cy.get('[data-cy=product-row]')
        .first()
        .within(() => {
          cy.get('[data-cy=edit-button]').click()
        })

      // 修改产品信息
      cy.get('[data-cy=name]').clear().type('Updated Product Name')
      cy.get('[data-cy=price]').clear().type('149.99')

      // 保存更改
      cy.get('[data-cy=save-product]').click()

      cy.checkNotification('产品更新成功')
    })
  })

  describe('批量操作', () => {
    beforeEach(() => {
      cy.visit('/admin/products')
    })

    it('应该支持批量删除', () => {
      // 选择多个产品
      cy.get('[data-cy=product-checkbox]').first().check()
      cy.get('[data-cy=product-checkbox]').eq(1).check()

      // 执行批量删除
      cy.get('[data-cy=bulk-delete]').click()
      cy.get('[data-cy=confirm-delete]').click()

      cy.checkNotification('已删除 2 个产品')
    })

    it('应该支持批量状态更新', () => {
      cy.get('[data-cy=select-all]').check()
      cy.get('[data-cy=bulk-status]').select('下架')
      cy.get('[data-cy=apply-bulk-action]').click()

      cy.checkNotification('批量操作完成')
    })
  })
})

API 测试

REST API 测试

// cypress/e2e/api/users.cy.js - API 测试
describe('用户 API', () => {
  const apiUrl = Cypress.env('apiUrl')
  let authToken

  before(() => {
    // 获取认证令牌
    cy.request({
      method: 'POST',
      url: `${apiUrl}/auth/login`,
      body: {
        email: Cypress.env('adminEmail'),
        password: Cypress.env('adminPassword'),
      },
    }).then((response) => {
      authToken = response.body.token
    })
  })

  beforeEach(() => {
    cy.clearDatabase()
    cy.seedDatabase()
  })

  describe('GET /users', () => {
    it('应该返回用户列表', () => {
      cy.request({
        method: 'GET',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
      }).then((response) => {
        expect(response.status).to.eq(200)
        expect(response.body).to.have.property('data')
        expect(response.body.data).to.be.an('array')
        expect(response.body.data.length).to.be.greaterThan(0)

        // 验证用户对象结构
        const user = response.body.data[0]
        expect(user).to.have.all.keys('id', 'name', 'email', 'role', 'createdAt', 'updatedAt')
        expect(user.email).to.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
      })
    })

    it('应该支持分页', () => {
      cy.request({
        method: 'GET',
        url: `${apiUrl}/users?page=1&limit=5`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
      }).then((response) => {
        expect(response.status).to.eq(200)
        expect(response.body.data).to.have.length.at.most(5)
        expect(response.body).to.have.property('pagination')
        expect(response.body.pagination).to.include.keys('page', 'limit', 'total', 'pages')
      })
    })

    it('应该支持搜索', () => {
      cy.request({
        method: 'GET',
        url: `${apiUrl}/users?search=admin`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
      }).then((response) => {
        expect(response.status).to.eq(200)
        response.body.data.forEach((user) => {
          expect(user.name.toLowerCase() + user.email.toLowerCase()).to.include('admin')
        })
      })
    })

    it('未认证用户应该返回 401', () => {
      cy.request({
        method: 'GET',
        url: `${apiUrl}/users`,
        failOnStatusCode: false,
      }).then((response) => {
        expect(response.status).to.eq(401)
        expect(response.body).to.have.property('error')
      })
    })
  })

  describe('POST /users', () => {
    it('应该成功创建用户', () => {
      const newUser = {
        name: 'Test User',
        email: 'test@example.com',
        password: 'password123',
        role: 'user',
      }

      cy.request({
        method: 'POST',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: newUser,
      }).then((response) => {
        expect(response.status).to.eq(201)
        expect(response.body.data).to.include({
          name: newUser.name,
          email: newUser.email,
          role: newUser.role,
        })
        expect(response.body.data).to.have.property('id')
        expect(response.body.data).to.not.have.property('password')
      })
    })

    it('应该验证必填字段', () => {
      cy.request({
        method: 'POST',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: {},
        failOnStatusCode: false,
      }).then((response) => {
        expect(response.status).to.eq(400)
        expect(response.body.errors).to.include.keys('name', 'email', 'password')
      })
    })

    it('应该验证邮箱唯一性', () => {
      const duplicateUser = {
        name: 'Duplicate User',
        email: Cypress.env('adminEmail'), // 使用已存在的邮箱
        password: 'password123',
        role: 'user',
      }

      cy.request({
        method: 'POST',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: duplicateUser,
        failOnStatusCode: false,
      }).then((response) => {
        expect(response.status).to.eq(409)
        expect(response.body.error).to.include('邮箱已存在')
      })
    })
  })

  describe('PUT /users/:id', () => {
    let userId

    beforeEach(() => {
      // 创建测试用户
      cy.request({
        method: 'POST',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: {
          name: 'Test User',
          email: 'test@example.com',
          password: 'password123',
          role: 'user',
        },
      }).then((response) => {
        userId = response.body.data.id
      })
    })

    it('应该成功更新用户', () => {
      const updateData = {
        name: 'Updated User',
        role: 'admin',
      }

      cy.request({
        method: 'PUT',
        url: `${apiUrl}/users/${userId}`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: updateData,
      }).then((response) => {
        expect(response.status).to.eq(200)
        expect(response.body.data).to.include(updateData)
      })
    })

    it('不存在的用户应该返回 404', () => {
      cy.request({
        method: 'PUT',
        url: `${apiUrl}/users/99999`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: { name: 'Updated Name' },
        failOnStatusCode: false,
      }).then((response) => {
        expect(response.status).to.eq(404)
      })
    })
  })

  describe('DELETE /users/:id', () => {
    let userId

    beforeEach(() => {
      cy.request({
        method: 'POST',
        url: `${apiUrl}/users`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        body: {
          name: 'Test User',
          email: 'test@example.com',
          password: 'password123',
          role: 'user',
        },
      }).then((response) => {
        userId = response.body.data.id
      })
    })

    it('应该成功删除用户', () => {
      cy.request({
        method: 'DELETE',
        url: `${apiUrl}/users/${userId}`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
      }).then((response) => {
        expect(response.status).to.eq(204)
      })

      // 验证用户已被删除
      cy.request({
        method: 'GET',
        url: `${apiUrl}/users/${userId}`,
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        failOnStatusCode: false,
      }).then((response) => {
        expect(response.status).to.eq(404)
      })
    })
  })
})

API 拦截和模拟

// cypress/e2e/api-mocking.cy.js - API 模拟测试
describe('API 模拟和拦截', () => {
  beforeEach(() => {
    cy.loginByAPI('user@example.com', 'password123')
  })

  describe('网络请求拦截', () => {
    it('应该拦截并模拟 API 响应', () => {
      // 拦截用户列表请求
      cy.intercept('GET', '/api/users', {
        statusCode: 200,
        body: {
          data: [
            { id: 1, name: 'Mock User 1', email: 'mock1@example.com' },
            { id: 2, name: 'Mock User 2', email: 'mock2@example.com' },
          ],
          pagination: { page: 1, limit: 10, total: 2, pages: 1 },
        },
      }).as('getUsers')

      cy.visit('/admin/users')

      cy.wait('@getUsers')
      cy.get('[data-cy=user-row]').should('have.length', 2)
      cy.get('[data-cy=user-row]').first().should('contain.text', 'Mock User 1')
    })

    it('应该模拟 API 错误', () => {
      cy.intercept('GET', '/api/users', {
        statusCode: 500,
        body: { error: '服务器内部错误' },
      }).as('getUsersError')

      cy.visit('/admin/users')

      cy.wait('@getUsersError')
      cy.get('[data-cy=error-message]').should('be.visible').and('contain.text', '加载用户列表失败')
    })

    it('应该模拟网络延迟', () => {
      cy.intercept('GET', '/api/users', (req) => {
        req.reply((res) => {
          res.delay(2000) // 2秒延迟
          res.send({
            statusCode: 200,
            body: { data: [], pagination: { page: 1, limit: 10, total: 0, pages: 0 } },
          })
        })
      }).as('getSlowUsers')

      cy.visit('/admin/users')

      // 验证加载状态
      cy.get('[data-cy=loading-spinner]').should('be.visible')
      cy.wait('@getSlowUsers')
      cy.get('[data-cy=loading-spinner]').should('not.exist')
    })

    it('应该动态修改请求', () => {
      cy.intercept('POST', '/api/users', (req) => {
        // 修改请求体
        req.body.role = 'admin'
        req.continue()
      }).as('createUser')

      cy.visit('/admin/users/new')

      cy.fillForm({
        name: 'Test User',
        email: 'test@example.com',
        password: 'password123',
        role: 'user', // 原始角色
      })

      cy.get('[data-cy=save-user]').click()

      cy.wait('@createUser').then((interception) => {
        expect(interception.request.body.role).to.eq('admin')
      })
    })
  })

  describe('条件拦截', () => {
    it('应该根据条件拦截不同的响应', () => {
      // 拦截搜索请求
      cy.intercept('GET', '/api/users*', (req) => {
        if (req.query.search === 'admin') {
          req.reply({
            statusCode: 200,
            body: {
              data: [{ id: 1, name: 'Admin User', email: 'admin@example.com', role: 'admin' }],
            },
          })
        } else if (req.query.search === 'empty') {
          req.reply({
            statusCode: 200,
            body: { data: [] },
          })
        } else {
          req.continue()
        }
      }).as('searchUsers')

      cy.visit('/admin/users')

      // 搜索管理员
      cy.get('[data-cy=search-input]').type('admin')
      cy.get('[data-cy=search-button]').click()

      cy.wait('@searchUsers')
      cy.get('[data-cy=user-row]').should('have.length', 1)
      cy.get('[data-cy=user-row]').should('contain.text', 'Admin User')

      // 搜索空结果
      cy.get('[data-cy=search-input]').clear().type('empty')
      cy.get('[data-cy=search-button]').click()

      cy.wait('@searchUsers')
      cy.get('[data-cy=no-results]').should('be.visible')
    })
  })

  describe('文件上传拦截', () => {
    it('应该拦截文件上传请求', () => {
      cy.intercept('POST', '/api/upload', {
        statusCode: 200,
        body: {
          url: 'https://example.com/uploaded-file.jpg',
          filename: 'uploaded-file.jpg',
        },
      }).as('uploadFile')

      cy.visit('/admin/products/new')

      cy.uploadFile('[data-cy=product-image]', 'product.jpg', 'image/jpeg')

      cy.wait('@uploadFile').then((interception) => {
        expect(interception.request.headers).to.have.property('content-type')
        expect(interception.request.headers['content-type']).to.include('multipart/form-data')
      })

      cy.get('[data-cy=image-preview]').should('be.visible')
    })
  })
})

高级测试技巧

可视化测试

// cypress/e2e/visual-testing.cy.js - 可视化测试
describe('可视化回归测试', () => {
  beforeEach(() => {
    cy.loginByAPI('user@example.com', 'password123')
  })

  it('应该匹配首页截图', () => {
    cy.visit('/')

    // 等待页面完全加载
    cy.get('[data-cy=hero-section]').should('be.visible')
    cy.get('[data-cy=loading-spinner]').should('not.exist')

    // 截图比较
    cy.compareSnapshot('homepage')
  })

  it('应该匹配登录表单截图', () => {
    cy.visit('/login')

    // 隐藏动态内容
    cy.get('[data-cy=current-time]').invoke('css', 'visibility', 'hidden')

    cy.compareSnapshot('login-form')
  })

  it('应该匹配不同视窗大小的截图', () => {
    const viewports = [
      { width: 320, height: 568 }, // iPhone SE
      { width: 768, height: 1024 }, // iPad
      { width: 1920, height: 1080 }, // Desktop
    ]

    viewports.forEach((viewport) => {
      cy.viewport(viewport.width, viewport.height)
      cy.visit('/dashboard')

      cy.get('[data-cy=dashboard-content]').should('be.visible')
      cy.compareSnapshot(`dashboard-${viewport.width}x${viewport.height}`)
    })
  })

  it('应该测试主题切换', () => {
    cy.visit('/dashboard')

    // 默认主题截图
    cy.compareSnapshot('dashboard-light-theme')

    // 切换到暗色主题
    cy.get('[data-cy=theme-toggle]').click()
    cy.get('body').should('have.class', 'dark-theme')

    cy.compareSnapshot('dashboard-dark-theme')
  })
})

// 自定义可视化测试命令
Cypress.Commands.add('compareSnapshot', (name, options = {}) => {
  const defaultOptions = {
    threshold: 0.1,
    thresholdType: 'percent',
    ...options,
  }

  cy.task('compareSnapshot', {
    name,
    options: defaultOptions,
    screenshot: true,
  })
})

性能测试

// cypress/e2e/performance.cy.js - 性能测试
describe('性能测试', () => {
  it('应该在合理时间内加载首页', () => {
    cy.visit('/', {
      onBeforeLoad: (win) => {
        win.performance.mark('start')
      },
      onLoad: (win) => {
        win.performance.mark('end')
        win.performance.measure('pageLoad', 'start', 'end')
      },
    })

    cy.window().then((win) => {
      const measure = win.performance.getEntriesByName('pageLoad')[0]
      expect(measure.duration).to.be.lessThan(3000) // 3秒内加载完成
    })
  })

  it('应该监控 Core Web Vitals', () => {
    cy.visit('/')

    cy.window()
      .then((win) => {
        return new Promise((resolve) => {
          new win.PerformanceObserver((list) => {
            const entries = list.getEntries()
            const vitals = {}

            entries.forEach((entry) => {
              if (entry.name === 'first-contentful-paint') {
                vitals.fcp = entry.startTime
              }
              if (entry.name === 'largest-contentful-paint') {
                vitals.lcp = entry.startTime
              }
            })

            resolve(vitals)
          }).observe({ entryTypes: ['paint', 'largest-contentful-paint'] })

          // 超时处理
          setTimeout(() => resolve({}), 5000)
        })
      })
      .then((vitals) => {
        if (vitals.fcp) {
          expect(vitals.fcp).to.be.lessThan(1800) // FCP < 1.8s
        }
        if (vitals.lcp) {
          expect(vitals.lcp).to.be.lessThan(2500) // LCP < 2.5s
        }
      })
  })

  it('应该测试资源加载性能', () => {
    cy.visit('/')

    cy.window().then((win) => {
      const resources = win.performance.getEntriesByType('resource')

      // 检查关键资源加载时间
      const criticalResources = resources.filter(
        (resource) =>
          resource.name.includes('.css') ||
          resource.name.includes('.js') ||
          resource.name.includes('api')
      )

      criticalResources.forEach((resource) => {
        expect(resource.duration).to.be.lessThan(1000) // 1秒内加载完成
      })

      // 检查总资源数量
      expect(resources.length).to.be.lessThan(50) // 资源数量控制
    })
  })
})

总结

Cypress 为现代 Web 应用提供了全面的测试解决方案:

  1. 直观的测试编写:类似 jQuery 的 API 设计
  2. 强大的调试能力:时间旅行和实时重载
  3. 全面的测试覆盖:UI、API、可视化测试
  4. 丰富的断言库:灵活的验证机制
  5. CI/CD 集成:完善的持续集成支持

掌握 Cypress,你就能构建出可靠、高效的端到端测试体系!


Cypress 是现代前端测试的首选工具,值得深入学习和实践。