文章目录
正文
1. Vue E2E测试简介
端到端(E2E)测试是验证Vue应用整体功能的方法,它通过模拟真实用户与应用的交互来测试完整的用户流程。
1.1 E2E测试的重要性
- 验证应用从前端到后端的完整功能流程
- 发现集成过程中产生的问题
- 测试真实用户场景和体验
- 确保业务关键流程的正确性
1.2 E2E测试工具对比
特性 | Cypress | Playwright |
---|---|---|
发布时间 | 2017年 | 2020年 |
开发商 | Cypress.io | Microsoft |
支持的浏览器 | Chrome, Firefox, Edge, Electron | Chrome, Firefox, Safari, Edge |
语言支持 | JavaScript/TypeScript | JavaScript/TypeScript, Python, Java, .NET |
并行测试 | 付费版支持 | 原生支持 |
调试体验 | 可视化时间轴, 即时重载 | 丰富的调试工具, 代码生成 |
社区活跃度 | 非常活跃 | 快速增长 |
2. Cypress 基础
Cypress是一个专为现代web应用设计的E2E测试框架,提供友好的开发体验和强大的调试功能。
2.1 Cypress 安装与设置
2.1.1 安装Cypress
# 在Vue项目中安装
npm install cypress --save-dev
# 或使用Vue CLI插件
vue add e2e-cypress
2.1.2 配置Cypress
创建或编辑cypress.config.js
文件:
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:8080',
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
viewportWidth: 1280,
viewportHeight: 720
},
});
2.1.3 基本目录结构
cypress/
├── e2e/ # 测试文件
├── fixtures/ # 测试数据
├── support/ # 辅助命令和配置
│ ├── commands.js
│ └── e2e.js
└── videos/ # 测试录制的视频
2.2 编写第一个Cypress测试
创建cypress/e2e/home.cy.js
文件:
describe('首页测试', () => {
beforeEach(() => {
// 每个测试前访问首页
cy.visit('/')
})
it('应该显示网站标题', () => {
cy.get('h1').should('contain', 'Vue应用')
})
it('导航菜单应该包含正确的链接', () => {
cy.get('nav')
.find('a')
.should('have.length.at.least', 2)
cy.get('nav').contains('首页')
cy.get('nav').contains('关于我们')
})
})
2.3 运行Cypress测试
# 打开Cypress测试运行器UI
npx cypress open
# 或直接在命令行运行所有测试
npx cypress run
3. Cypress 核心概念
3.1 Cypress命令与断言
3.1.1 基本命令
// 导航到URL
cy.visit('/login')
// 选择元素
cy.get('#username')
// 输入文本
cy.get('#username').type('testuser')
// 点击元素
cy.get('button').click()
// 等待特定时间
cy.wait(1000) // 等待1秒
// 等待特定请求
cy.intercept('GET', '/api/users').as('getUsers')
cy.wait('@getUsers')
3.1.2 常用断言
// 检查文本内容
cy.get('h1').should('contain', '欢迎登录')
// 检查可见性
cy.get('.error-message').should('be.visible')
// 检查元素数量
cy.get('li').should('have.length', 5)
// 检查属性值
cy.get('input').should('have.attr', 'placeholder', '请输入用户名')
// 检查CSS类
cy.get('button').should('have.class', 'active')
// 链式断言
cy.get('button')
.should('be.enabled')
.and('have.css', 'background-color', 'rgb(0, 123, 255)')
3.2 处理异步操作
Cypress命令是异步的,但它自动处理等待和重试机制。
// Cypress会自动等待元素出现
cy.get('.dynamic-content', { timeout: 10000 }).should('be.visible')
// 等待网络请求
cy.intercept('POST', '/api/login').as('loginRequest')
cy.get('#login-button').click()
cy.wait('@loginRequest')
.its('response.statusCode')
.should('eq', 200)
// 等待Vue组件渲染完成
cy.get('[data-cy=component]').should('exist')
3.3 网络请求模拟
3.3.1 拦截与监听请求
// 监听API调用
cy.intercept('GET', '/api/users').as('getUsers')
cy.visit('/dashboard')
cy.wait('@getUsers')
// 模拟API响应
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers')
cy.visit('/dashboard')
cy.wait('@getUsers')
// 动态响应
cy.intercept('GET', '/api/users', (req) => {
req.reply({
statusCode: 200,
body: {
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
]
}
})
})
3.3.2 模拟错误状态
// 模拟服务器错误
cy.intercept('POST', '/api/submit', {
statusCode: 500,
body: { error: '服务器内部错误' }
}).as('submitForm')
cy.get('form').submit()
cy.wait('@submitForm')
cy.get('.error-message').should('be.visible')
4. Cypress进阶技巧
4.1 自定义命令
在cypress/support/commands.js
中添加:
// 登录命令
Cypress.Commands.add('login', (username, password) => {
cy.visit('/login')
cy.get('#username').type(username)
cy.get('#password').type(password)
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
})
// 检查Toast消息
Cypress.Commands.add('checkToast', (message) => {
cy.get('.toast-message')
.should('be.visible')
.and('contain', message)
})
// 添加商品到购物车
Cypress.Commands.add('addToCart', (productId) => {
cy.get(`[data-product-id="${productId}"]`)
.find('.add-to-cart')
.click()
cy.get('.cart-count').should('not.contain', '0')
})
在测试中使用自定义命令:
describe('购物车测试', () => {
beforeEach(() => {
cy.login('testuser', 'password123')
})
it('应该能将商品添加到购物车', () => {
cy.visit('/products')
cy.addToCart(1)
cy.checkToast('商品已添加到购物车')
})
})
4.2 测试Vue组件
4.2.1 组件数据测试属性
在Vue组件中添加data-cy
属性:
<template>
<div>
<input
data-cy="search-input"
v-model="searchTerm"
placeholder="搜索..."
/>
<button data-cy="search-button" @click="search">搜索</button>
<div v-if="loading" data-cy="loading-indicator">加载中...</div>
<ul v-else>
<li
v-for="result in results"
:key="result.id"
data-cy="search-result"
>
{{ result.name }}
</li>
</ul>
</div>
</template>
在测试中使用这些属性:
describe('搜索组件', () => {
beforeEach(() => {
cy.visit('/search')
})
it('成功搜索并展示结果', () => {
// 拦截API请求
cy.intercept('GET', '/api/search*', {
results: [
{ id: 1, name: '搜索结果1' },
{ id: 2, name: '搜索结果2' }
]
}).as('searchRequest')
// 执行搜索
cy.get('[data-cy=search-input]').type('测试')
cy.get('[data-cy=search-button]').click()
// 检查加载状态
cy.get('[data-cy=loading-indicator]').should('be.visible')
cy.wait('@searchRequest')
cy.get('[data-cy=loading-indicator]').should('not.exist')
// 验证结果显示
cy.get('[data-cy=search-result]').should('have.length', 2)
cy.get('[data-cy=search-result]').first().should('contain', '搜索结果1')
})
})
4.2.2 测试Vuex状态
// 测试与Vuex交互的组件
describe('Todo组件', () => {
it('应该通过Vuex添加新待办事项', () => {
cy.visit('/todos')
// 监听Vuex action
cy.window().then((win) => {
cy.spy(win.store, 'dispatch').as('addTodoAction')
})
// 添加待办事项
cy.get('[data-cy=new-todo]').type('学习E2E测试{enter}')
// 验证Vuex action被调用
cy.get('@addTodoAction').should('be.calledWith', 'addTodo', {
text: '学习E2E测试',
completed: false
})
// 验证UI更新
cy.get('[data-cy=todo-item]').should('contain', '学习E2E测试')
})
})
4.3 测试文件上传
describe('文件上传', () => {
it('应该上传文件并显示预览', () => {
cy.visit('/upload')
// 准备文件并上传
cy.get('input[type=file]').attachFile({
filePath: 'test-image.jpg',
mimeType: 'image/jpeg',
encoding: 'base64'
})
// 验证上传成功
cy.get('.file-preview').should('be.visible')
cy.get('.file-name').should('contain', 'test-image.jpg')
})
})
5. Playwright 基础
Playwright是由Microsoft开发的较新的E2E测试工具,支持多种浏览器和编程语言。
5.1 Playwright 安装与设置
5.1.1 安装Playwright
# 在Vue项目中安装
npm init playwright@latest
# 或使用Vue CLI插件
vue add e2e-playwright
安装过程中会提示选择浏览器、测试位置等选项。
5.1.2 配置Playwright
编辑playwright.config.js
文件:
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
reporter: 'html',
use: {
baseURL: 'http://localhost:8080',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
5.1.3 基本目录结构
tests/
├── e2e/ # 测试文件
│ └── example.spec.js
├── fixtures/ # 测试数据
└── support/ # 辅助函数和配置
5.2 编写第一个Playwright测试
创建tests/e2e/home.spec.js
文件:
const { test, expect } = require('@playwright/test');
test.describe('首页测试', () => {
test.beforeEach(async ({ page }) => {
// 每个测试前访问首页
await page.goto('/');
});
test('应该显示网站标题', async ({ page }) => {
await expect(page.locator('h1')).toContainText('Vue应用');
});
test('导航菜单应该包含正确的链接', async ({ page }) => {
const navLinks = page.locator('nav a');
await expect(navLinks).toHaveCount(3);
await expect(page.locator('nav')).toContainText('首页');
await expect(page.locator('nav')).toContainText('关于我们');
});
});
5.3 运行Playwright测试
# 运行所有测试
npx playwright test
# 在UI模式下运行测试
npx playwright test --ui
# 只在特定浏览器中运行
npx playwright test --project=chromium
# 运行单个测试文件
npx playwright test home.spec.js
6. Playwright 核心概念
6.1 定位元素与断言
6.1.1 定位元素
// 使用CSS选择器
const button = page.locator('button.submit');
// 使用文本内容
const loginLink = page.getByText('登录');
// 使用role
const submitButton = page.getByRole('button', { name: '提交' });
// 使用标签
const usernameInput = page.getByLabel('用户名');
// 使用测试ID (推荐)
const searchBox = page.getByTestId('search-input');
// 组合定位
const item = page.locator('.list-item').filter({ hasText: '项目3' });
6.1.2 常用断言
// 检查文本内容
await expect(page.locator('h1')).toContainText('欢迎');
// 检查可见性
await expect(page.locator('.error')).toBeVisible();
// 检查元素数量
await expect(page.locator('li')).toHaveCount(5);
// 检查属性
await expect(page.locator('input')).toHaveAttribute('placeholder', '搜索');
// 检查CSS类
await expect(page.locator('button')).toHaveClass(/active/);
// 检查页面标题
await expect(page).toHaveTitle(/Vue应用/);
// 检查URL
await expect(page).toHaveURL(/\/dashboard/);
6.2 交互与等待
// 点击元素
await page.locator('button').click();
// 填写文本
await page.locator('#username').fill('testuser');
// 选择下拉选项
await page.locator('select').selectOption('选项1');
// 检查复选框
await page.locator('input[type="checkbox"]').check();
// 鼠标悬停
await page.locator('.dropdown').hover();
// 键盘操作
await page.locator('input').press('Enter');
// 等待元素
await page.locator('.dynamic-content').waitFor({ state: 'visible' });
// 等待导航
await Promise.all([
page.waitForNavigation(),
page.locator('a').click()
]);
// 等待网络请求
const responsePromise = page.waitForResponse('**/api/users');
await page.locator('button').click();
const response = await responsePromise;
expect(response.status()).toBe(200);
6.3 网络请求模拟
6.3.1 拦截与模拟请求
// 拦截请求并提供模拟响应
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
])
});
});
// 拦截并阻止某些请求
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
// 修改请求
await page.route('**/api/data', async route => {
const request = route.request();
const postData = JSON.parse(request.postData() || '{}');
postData.additionalParam = 'value';
await route.continue({
postData: JSON.stringify(postData)
});
});
6.3.2 监听网络请求
// 监听所有网络请求
page.on('request', request => {
console.log(`>> ${request.method()} ${request.url()}`);
});
page.on('response', response => {
console.log(`<< ${response.status()} ${response.url()}`);
});
// 等待特定请求完成
const [response] = await Promise.all([
page.waitForResponse('**/api/login'),
page.locator('#login-button').click()
]);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.success).toBeTruthy();
7. Playwright 进阶技巧
7.1 Playwright 测试挂钩和夹具
const { test, expect } = require('@playwright/test');
// 创建测试夹具 (fixture)
test.beforeAll(async ({ browser }) => {
// 在所有测试前执行一次
const context = await browser.newContext();
const page = await context.newPage();
// 登录并保存状态
await page.goto('/login');
await page.fill('#username', 'admin');
await page.fill('#password', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// 保存认证状态
await context.storageState({ path: './auth.json' });
});
// 使用已认证状态的夹具
test.use({ storageState: './auth.json' });
test('已登录用户可以查看仪表板', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('h1')).toContainText('欢迎回来');
});
7.2 自定义工具函数
创建tests/support/helpers.js
:
// 等待Vue组件挂载完成
async function waitForVueReady(page) {
await page.waitForFunction(() => {
return window.__VUE_DEVTOOLS_GLOBAL_HOOK__?.Vue?.nextTick !== undefined;
});
}
// 检查Vuex状态
async function getVuexState(page) {
return page.evaluate(() => {
return window.$store.state;
});
}
// 切换深色/浅色模式
async function toggleDarkMode(page) {
await page.locator('[data-testid="theme-toggle"]').click();
// 等待CSS变化应用
await page.waitForTimeout(100);
}
module.exports = {
waitForVueReady,
getVuexState,
toggleDarkMode
};
在测试中使用:
const { test, expect } = require('@playwright/test');
const { waitForVueReady, getVuexState } = require('../support/helpers');
test('测试深色模式切换', async ({ page }) => {
await page.goto('/');
await waitForVueReady(page);
// 获取初始状态
const initialState = await getVuexState(page);
expect(initialState.theme).toBe('light');
// 切换主题
await page.locator('[data-testid="theme-toggle"]').click();
// 验证状态变化
const updatedState = await getVuexState(page);
expect(updatedState.theme).toBe('dark');
// 验证CSS变化
await expect(page.locator('body')).toHaveClass(/dark-theme/);
});
7.3 测试视觉对比
const { test, expect } = require('@playwright/test');
test('首页视觉无回归', async ({ page }) => {
await page.goto('/');
// 等待内容完全加载
await page.waitForSelector('.main-content', { state: 'visible' });
// 截屏并与基准图进行比较
await expect(page).toHaveScreenshot('home-page.png');
});
test('组件视觉无回归', async ({ page }) => {
await page.goto('/components');
// 针对特定组件截屏
await expect(page.locator('.card-component')).toHaveScreenshot('card.png');
});
8. 测试真实场景与用户流程
8.1 完整注册-登录-购买流程测试
使用Cypress:
describe('电商购买流程', () => {
it('新用户应该能注册、登录并完成购买', () => {
// 随机生成用户信息
const email = `test${Date.now()}@example.com`;
const password = 'Password123!';
// 1. 注册流程
cy.visit('/register');
cy.get('[data-cy=username]').type(`user${Date.now()}`);
cy.get('[data-cy=email]').type(email);
cy.get('[data-cy=password]').type(password);
cy.get('[data-cy=confirm-password]').type(password);
cy.get('[data-cy=register-button]').click();
cy.url().should('include', '/login');
// 2. 登录流程
cy.get('[data-cy=email]').type(email);
cy.get('[data-cy=password]').type(password);
cy.get('[data-cy=login-button]').click();
cy.url().should('include', '/dashboard');
// 3. 浏览商品
cy.visit('/products');
cy.get('[data-cy=product-card]').first().click();
cy.url().should('include', '/product/');
// 4. 添加到购物车
cy.get('[data-cy=add-to-cart]').click();
cy.get('[data-cy=cart-count]').should('contain', '1');
// 5. 查看购物车
cy.get('[data-cy=cart-icon]').click();
cy.url().should('include', '/cart');
cy.get('[data-cy=cart-item]').should('have.length', 1);
// 6. 结账流程
cy.get('[data-cy=checkout-button]').click();
cy.url().should('include', '/checkout');
// 7. 填写配送信息
cy.get('[data-cy=address]').type('测试地址');
cy.get('[data-cy=city]').type('北京');
cy.get('[data-cy=zip]').type('100000');
cy.get('[data-cy=phone]').type('13800138000');
// 8. 选择支付方式
cy.get('[data-cy=payment-method]').select('支付宝');
// 9. 提交订单
cy.intercept('POST', '/api/orders').as('createOrder');
cy.get('[data-cy=place-order]').click();
cy.wait('@createOrder');
// 10. 确认订单成功
cy.url().should('include', '/order-confirmation');
cy.get('[data-cy=order-success]').should('be.visible');
cy.get('[data-cy=order-number]').should('not.be.empty');
});
});
使用Playwright:
const { test, expect } = require('@playwright/test');
test('完整电商购买流程', async ({ page }) => {
// 随机生成用户信息
const email = `test${Date.now()}@example.com`;
const password = 'Password123!';
// 1. 注册流程
await page.goto('/register');
await page.fill('[data-testid="username"]', `user${Date.now()}`);
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', password);
await page.fill('[data-testid="confirm-password"]', password);
await Promise.all([
page.waitForNavigation(),
page.click('[data-testid="register-button"]')
]);
expect(page.url()).toContain('/login');
// 2. 登录流程
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', password);
await Promise.all([
page.waitForNavigation(),
page.click('[data-testid="login-button"]')
]);
expect(page.url()).toContain('/dashboard');
// 3~10. (与Cypress示例类似的后续流程)
// ...
});
8.2 测试响应式布局
使用Cypress:
describe('响应式布局测试', () => {
it('在手机视图中应正确显示导航菜单', () => {
// 设置手机视口
cy.viewport('iphone-x');
cy.visit('/');
// 验证菜单按钮存在
cy.get('[data-cy=mobile-menu-button]').should('be.visible');
cy.get('nav').should('not.be.visible');
// 打开菜单
cy.get('[data-cy=mobile-menu-button]').click();
cy.get('nav').should('be.visible');
// 验证菜单项
cy.get('nav a').should('have.length.at.least', 3);
});
it('在桌面视图中应直接显示导航栏', () => {
// 设置桌面视口
cy.viewport(1280, 720);
cy.visit('/');
// 验证菜单按钮不存在或不可见
cy.get('[data-cy=mobile-menu-button]').should('not.be.visible');
// 导航栏直接可见
cy.get('nav').should('be.visible');
cy.get('nav a').should('have.length.at.least', 3);
});
});
使用Playwright:
const { test, expect } = require('@playwright/test');
const { devices } = require('@playwright/test');
// 手机视图测试
test.use({ viewport: devices['iPhone X'].viewport });
test('在手机视图中测试导航菜单', async ({ page }) => {
await page.goto('/');
// 验证菜单按钮存在
const menuButton = page.locator('[data-testid="mobile-menu-button"]');
await expect(menuButton).toBeVisible();
// 验证导航菜单默认隐藏
const nav = page.locator('nav');
await expect(nav).not.toBeVisible();
// 打开菜单
await menuButton.click();
await expect(nav).toBeVisible();
});
// 桌面视图测试
test.describe('桌面视图测试', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('桌面视图应直接显示导航栏', async ({ page }) => {
await page.goto('/');
// 验证菜单按钮不可见
const menuButton = page.locator('[data-testid="mobile-menu-button"]');
await expect(menuButton).not.toBeVisible();
// 导航栏直接可见
const nav = page.locator('nav');
await expect(nav).toBeVisible();
});
});
结语
感谢您的阅读!期待您的一键三连!欢迎指正!