第12章 测试策略
第33节 集成测试与E2E测试
12.33.1 Vue Test Utils进阶使用
模拟依赖
在测试组件时,我们经常需要模拟外部依赖,例如API请求、路由、状态管理等。Vue Test Utils提供了多种方式来模拟这些依赖。
模拟组件
当测试一个包含子组件的组件时,我们可以使用stubs选项来模拟子组件:
// src/components/ParentComponent.vue
<template>
<div>
<h2>Parent Component</h2>
<ChildComponent :message="parentMessage" @update="handleUpdate" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentMessage = ref('Hello from Parent')
const handleUpdate = (newMessage) => {
parentMessage.value = newMessage
}
</script>测试时模拟子组件:
// src/components/ParentComponent.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ParentComponent from './ParentComponent.vue'
describe('ParentComponent.vue', () => {
it('should pass message to child component', () => {
const wrapper = mount(ParentComponent, {
stubs: {
ChildComponent: {
template: '<div><slot></slot></div>',
props: ['message'],
emits: ['update']
}
}
})
expect(wrapper.exists()).toBe(true)
})
it('should handle update event from child component', async () => {
const wrapper = mount(ParentComponent, {
stubs: {
ChildComponent: {
template: '<button @click="$emit(\'update\', \'Updated from Child\')">Update</button>',
props: ['message'],
emits: ['update']
}
}
})
await wrapper.find('button').trigger('click')
// 验证父组件是否处理了子组件的事件
})
})模拟路由
当测试使用Vue Router的组件时,我们需要模拟路由环境:
// src/components/RouteComponent.vue
<template>
<div>
<h2>Route Component</h2>
<p>Current Route: {{ $route.path }}</p>
<router-link to="/about">About</router-link>
<button @click="goToHome">Go to Home</button>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goToHome = () => {
router.push('/')
}
</script>测试时模拟路由:
// src/components/RouteComponent.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import RouteComponent from './RouteComponent.vue'
describe('RouteComponent.vue', () => {
it('should render current route', () => {
const wrapper = mount(RouteComponent, {
global: {
mocks: {
$route: { path: '/test' },
$router: {
push: vi.fn()
}
}
}
})
expect(wrapper.find('p').text()).toContain('/test')
})
it('should call router.push when button is clicked', async () => {
const pushMock = vi.fn()
const wrapper = mount(RouteComponent, {
global: {
mocks: {
$route: { path: '/test' },
$router: {
push: pushMock
}
}
}
})
await wrapper.find('button').trigger('click')
expect(pushMock).toHaveBeenCalledWith('/')
})
})模拟状态管理
当测试使用Pinia的组件时,我们需要模拟状态管理:
// src/components/StoreComponent.vue
<template>
<div>
<h2>Store Component</h2>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
const count = counterStore.count
const increment = counterStore.increment
</script>测试时模拟状态管理:
// src/components/StoreComponent.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import StoreComponent from './StoreComponent.vue'
describe('StoreComponent.vue', () => {
it('should render count from store', () => {
const wrapper = mount(StoreComponent, {
global: {
plugins: [createTestingPinia({
initialState: {
counter: {
count: 5
}
}
})]
}
})
expect(wrapper.find('p').text()).toContain('5')
})
it('should call increment when button is clicked', async () => {
const wrapper = mount(StoreComponent, {
global: {
plugins: [createTestingPinia()]
}
})
await wrapper.find('button').trigger('click')
// 验证increment方法是否被调用
})
})测试异步组件
Vue 3支持异步组件,我们可以使用defineAsyncComponent来定义异步组件。测试异步组件时,我们需要处理异步加载的情况。
// src/components/AsyncComponent.vue
<template>
<div>
<h2>Async Component</h2>
<Suspense>
<template #default>
<AsyncChildComponent />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncChildComponent = defineAsyncComponent(() =>
import('./AsyncChildComponent.vue')
)
</script>测试异步组件:
// src/components/AsyncComponent.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AsyncComponent from './AsyncComponent.vue'
describe('AsyncComponent.vue', () => {
it('should render fallback content initially', () => {
const wrapper = mount(AsyncComponent)
expect(wrapper.find('div').text()).toBe('Loading...')
})
it('should render async component when loaded', async () => {
// 使用vi.mock来模拟异步组件
vi.mock('./AsyncChildComponent.vue', () => ({
default: {
template: '<div>Async Child</div>'
}
}))
const wrapper = mount(AsyncComponent)
// 等待异步组件加载
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Async Child')
})
})12.33.2 Testing Library最佳实践
Testing Library简介
Testing Library是一个用于测试UI组件的库,它强调测试组件的行为而不是实现细节。它提供了一系列查询方法,用于查找DOM元素,以及事件触发方法,用于模拟用户交互。
Vue Testing Library是Testing Library的Vue版本,它与Vue Test Utils集成,提供了更符合用户行为的测试方式。
安装Vue Testing Library
npm install @testing-library/vue @testing-library/jest-dom -D查询原则
Testing Library强调使用用户可见的内容来查找元素,而不是使用选择器或组件内部实现细节。它提供了以下查询方法:
getBy...: 查找匹配的元素,如果没有找到或找到多个则抛出错误findBy...: 异步查找匹配的元素,返回PromisequeryBy...: 查找匹配的元素,如果没有找到则返回nullgetAllBy...: 查找所有匹配的元素,返回数组findAllBy...: 异步查找所有匹配的元素,返回PromisequeryAllBy...: 查找所有匹配的元素,返回数组
查询后缀包括:
Role: 根据ARIA角色查找LabelText: 根据标签文本查找PlaceholderText: 根据占位符文本查找Text: 根据文本内容查找DisplayValue: 根据显示值查找AltText: 根据alt属性查找Title: 根据title属性查找TestId: 根据data-testid属性查找
编写Testing Library测试
<!-- src/components/LoginForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="username">Username</label>
<input
id="username"
v-model="username"
placeholder="Enter username"
data-testid="username-input"
>
</div>
<div>
<label for="password">Password</label>
<input
id="password"
type="password"
v-model="password"
placeholder="Enter password"
>
</div>
<button type="submit" :disabled="!isFormValid">Login</button>
<div v-if="error" class="error">{{ error }}</div>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const emit = defineEmits(['login'])
const username = ref('')
const password = ref('')
const error = ref('')
const isFormValid = computed(() => {
return username.value && password.value
})
const handleSubmit = () => {
if (username.value === 'admin' && password.value === 'password') {
emit('login', { username: username.value })
} else {
error.value = 'Invalid credentials'
}
}
</script>使用Testing Library测试:
// src/components/LoginForm.test.js
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/vue'
import LoginForm from './LoginForm.vue'
describe('LoginForm.vue', () => {
it('should render login form', () => {
render(LoginForm)
expect(screen.getByLabelText('Username')).toBeInTheDocument()
expect(screen.getByLabelText('Password')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('should disable submit button when form is invalid', () => {
render(LoginForm)
const button = screen.getByRole('button', { name: /login/i })
expect(button).toBeDisabled()
})
it('should enable submit button when form is valid', async () => {
render(LoginForm)
await fireEvent.update(screen.getByLabelText('Username'), 'admin')
await fireEvent.update(screen.getByLabelText('Password'), 'password')
const button = screen.getByRole('button', { name: /login/i })
expect(button).not.toBeDisabled()
})
it('should show error message for invalid credentials', async () => {
render(LoginForm)
await fireEvent.update(screen.getByLabelText('Username'), 'wrong')
await fireEvent.update(screen.getByLabelText('Password'), 'wrong')
await fireEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
it('should emit login event for valid credentials', async () => {
const { emitted } = render(LoginForm)
await fireEvent.update(screen.getByLabelText('Username'), 'admin')
await fireEvent.update(screen.getByLabelText('Password'), 'password')
await fireEvent.click(screen.getByRole('button', { name: /login/i }))
expect(emitted('login')).toHaveLength(1)
expect(emitted('login')[0][0]).toEqual({ username: 'admin' })
})
})异步测试
Testing Library提供了findBy...方法来测试异步内容:
<!-- src/components/AsyncData.vue -->
<template>
<div>
<button @click="fetchData">Fetch Data</button>
<div v-if="loading">Loading...</div>
<div v-else-if="data">
<h3>{{ data.title }}</h3>
<p>{{ data.description }}</p>
</div>
<div v-else-if="error">{{ error }}</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const loading = ref(false)
const data = ref(null)
const error = ref('')
const fetchData = async () => {
loading.value = true
try {
const response = await axios.get('/api/data')
data.value = response.data
} catch (err) {
error.value = 'Failed to fetch data'
} finally {
loading.value = false
}
}
</script>测试异步组件:
// src/components/AsyncData.test.js
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/vue'
import axios from 'axios'
import AsyncData from './AsyncData.vue'
// 模拟axios
vi.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('AsyncData.vue', () => {
it('should fetch data when button is clicked', async () => {
// 设置模拟返回值
mockedAxios.get.mockResolvedValueOnce({
data: {
title: 'Test Title',
description: 'Test Description'
}
})
render(AsyncData)
await fireEvent.click(screen.getByRole('button', { name: /fetch data/i }))
// 等待加载状态
expect(screen.getByText('Loading...')).toBeInTheDocument()
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should show error when fetch fails', async () => {
// 设置模拟返回错误
mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'))
render(AsyncData)
await fireEvent.click(screen.getByRole('button', { name: /fetch data/i }))
// 等待错误信息显示
await waitFor(() => {
expect(screen.getByText('Failed to fetch data')).toBeInTheDocument()
})
})
})12.33.3 Cypress E2E测试配置
Cypress简介
Cypress是一个端到端测试框架,它允许我们在真实浏览器中测试整个应用程序。它具有以下特点:
- 实时重新加载
- 自动等待
- 丰富的调试功能
- 支持网络请求拦截
- 支持截图和录屏
安装Cypress
npm install cypress -D配置Cypress
在package.json中添加Cypress脚本:
{
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}运行Cypress初始化:
npm run cypress:open这将创建一个cypress目录,包含以下结构:
cypress/
├── fixtures/ # 测试数据
├── integration/ # 测试文件
├── plugins/ # 插件配置
├── support/ # 支持文件
└── cypress.json # Cypress配置配置cypress.json:
{
"baseUrl": "http://localhost:3000",
"viewportWidth": 1280,
"viewportHeight": 720,
"defaultCommandTimeout": 5000,
"video": true,
"screenshotOnRunFailure": true
}编写第一个E2E测试
// cypress/integration/login.spec.js
describe('Login Flow', () => {
beforeEach(() => {
// 访问登录页面
cy.visit('/login')
})
it('should render login page', () => {
// 验证页面元素
cy.contains('Login').should('be.visible')
cy.get('input#username').should('be.visible')
cy.get('input#password').should('be.visible')
cy.get('button[type="submit"]').should('be.visible')
})
it('should login with valid credentials', () => {
// 输入用户名和密码
cy.get('input#username').type('admin')
cy.get('input#password').type('password')
// 提交表单
cy.get('button[type="submit"]').click()
// 验证是否跳转到首页
cy.url().should('eq', 'http://localhost:3000/')
cy.contains('Welcome, admin').should('be.visible')
})
it('should show error with invalid credentials', () => {
// 输入无效的用户名和密码
cy.get('input#username').type('wrong')
cy.get('input#password').type('wrong')
// 提交表单
cy.get('button[type="submit"]').click()
// 验证错误信息
cy.contains('Invalid credentials').should('be.visible')
})
it('should disable submit button when form is invalid', () => {
// 只输入用户名
cy.get('input#username').type('admin')
// 验证按钮是否禁用
cy.get('button[type="submit"]').should('be.disabled')
// 输入密码
cy.get('input#password').type('password')
// 验证按钮是否启用
cy.get('button[type="submit"]').should('not.be.disabled')
})
})拦截网络请求
Cypress允许我们拦截和模拟网络请求:
// cypress/integration/products.spec.js
describe('Products Page', () => {
beforeEach(() => {
// 拦截API请求并返回模拟数据
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{
id: 1,
name: 'Product 1',
price: 100
},
{
id: 2,
name: 'Product 2',
price: 200
}
]
}).as('getProducts')
// 访问产品页面
cy.visit('/products')
})
it('should display products', () => {
// 等待API请求完成
cy.wait('@getProducts')
// 验证产品列表
cy.contains('Product 1').should('be.visible')
cy.contains('Product 2').should('be.visible')
cy.contains('$100').should('be.visible')
cy.contains('$200').should('be.visible')
})
it('should filter products', () => {
// 等待API请求完成
cy.wait('@getProducts')
// 输入搜索关键词
cy.get('input[data-testid="search-input"]').type('Product 1')
// 验证只显示匹配的产品
cy.contains('Product 1').should('be.visible')
cy.contains('Product 2').should('not.exist')
})
})Cypress最佳实践
- 使用数据属性:使用
data-testid属性来标识测试元素,避免依赖CSS选择器或文本内容 - 保持测试独立:每个测试应该是独立的,不依赖其他测试的状态
- 使用beforeEach:使用beforeEach来设置测试环境
- 等待异步操作:使用
cy.wait()或断言来等待异步操作完成 - 避免硬编码URL:使用
baseUrl配置和相对路径 - 使用拦截请求:使用
cy.intercept()来模拟API请求,避免依赖真实后端 - 保持测试简单:每个测试应该只测试一个功能点
- 使用自定义命令:将重复的测试逻辑提取为自定义命令
12.33.4 测试覆盖率与持续集成
覆盖率报告生成
我们可以使用Vitest生成覆盖率报告:
npm run test:coverage这将在coverage目录下生成覆盖率报告,包括HTML、JSON和文本格式。
覆盖率报告包含以下指标:
- 语句覆盖率:执行了多少语句
- 分支覆盖率:执行了多少分支
- 函数覆盖率:执行了多少函数
- 行覆盖率:执行了多少行
持续集成配置
我们可以在CI/CD管道中集成测试,确保每次代码变更都通过测试。
GitHub Actions配置
# .github/workflows/test.yml
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 16.x, 18.x ]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build --if-present
- run: npm run test:run
- run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.jsonGitLab CI配置
# .gitlab-ci.yml
image: node:18
stages:
- install
- build
- test
install:
stage: install
script:
- npm ci
artifacts:
paths:
- node_modules/
build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
unit-test:
stage: test
script:
- npm run test:run
coverage:
stage: test
script:
- npm run test:coverage
artifacts:
paths:
- coverage/
cypress-test:
stage: test
script:
- npm run dev &
- npx wait-on http://localhost:3000
- npm run cypress:run
artifacts:
paths:
- cypress/videos/
- cypress/screenshots/
when: always测试策略建议
测试金字塔:
- 单元测试:70%
- 集成测试:20%
- E2E测试:10%
测试重点:
- 核心业务逻辑
- 用户关键路径
- 边界情况
- 错误处理
测试原则:
- 快速:测试应该快速运行
- 可靠:测试应该稳定,不应该有随机失败
- 清晰:测试应该易于理解和维护
- 全面:测试应该覆盖主要功能
持续改进:
- 定期审查测试覆盖率
- 移除过时的测试
- 重构测试代码
- 学习新的测试技术
最佳实践
选择合适的测试工具:
- 单元测试:Vitest + Vue Test Utils
- 组件测试:Vue Testing Library
- E2E测试:Cypress
测试命名规范:
- 测试文件:
*.test.js或*.spec.js - 测试套件:使用描述性名称
- 测试用例:使用"should"开头
- 测试文件:
测试隔离:
- 每个测试应该独立运行
- 避免测试之间的依赖
- 使用beforeEach/afterEach来设置和清理测试环境
模拟与真实测试:
- 单元测试:使用模拟依赖
- 集成测试:使用部分真实依赖
- E2E测试:使用真实环境
持续集成:
- 每次提交都运行测试
- 生成覆盖率报告
- 配置测试失败通知
小结
本节我们学习了集成测试与E2E测试的基本概念和实践,包括:
- Vue Test Utils进阶使用,包括模拟依赖、测试异步组件、测试路由组件和状态管理组件
- Testing Library最佳实践,包括查询原则、事件触发和异步测试
- Cypress E2E测试配置,包括安装、配置、编写测试和拦截网络请求
- 测试覆盖率与持续集成,包括覆盖率报告生成和CI配置
通过合理的测试策略,我们可以确保应用程序的质量和稳定性,同时提高开发效率和代码可维护性。在实际项目中,我们应该根据项目的具体情况来选择合适的测试工具和策略,平衡测试覆盖率和开发效率。
思考与练习
- 安装Cypress并配置测试环境。
- 编写一个测试登录流程的E2E测试。
- 编写一个测试产品列表的E2E测试,包括拦截网络请求。
- 配置GitHub Actions或GitLab CI,集成测试和覆盖率报告。
- 使用Testing Library测试一个表单组件。