- 发布于
Cypress 端到端测试指南:现代 Web 应用的自动化测试解决方案
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
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 应用提供了全面的测试解决方案:
- 直观的测试编写:类似 jQuery 的 API 设计
- 强大的调试能力:时间旅行和实时重载
- 全面的测试覆盖:UI、API、可视化测试
- 丰富的断言库:灵活的验证机制
- CI/CD 集成:完善的持续集成支持
掌握 Cypress,你就能构建出可靠、高效的端到端测试体系!
Cypress 是现代前端测试的首选工具,值得深入学习和实践。