BDD 介绍
TDD 的问题
- 由于是以单元测试为主,会导致做出来的东西和实际功能需求相偏离
- 过于依赖被测试功能的实现逻辑导致测试代码和实现代码耦合太高难以维护
BDD 行为驱动开发
- 不需要再面向实现细节设计测试,取而代之的是面向行为来测试
- BDD 的核心是关注软件的功能测试,所以 BDD 更多的是结合集成测试进行
BDD 开发流程
- 开发人员和非开发人员一起讨论确认需求
- 以一种自动化的方式将需求建立起来,并确认是否一致
- 最后,实现每个文档示例描述的行为,并从自动化测试开始以指导代码的开发
- 功能验收
BDD 解决方案和流程
Cucumber
https://cucumber.io/
- 需求分析
- 使用 Gherkin 语法描述需求
- 将 Gherkin 描述的需求文档映射为自动化测试用例
- 编写代码以通过测试
- 功能验收
通常需求描述文档由产品编写。
BDD + TDD
- 需求分析
- 将需求映射为集成测试用例
- 单元测试
- 编写代码以通过单元测试
- 验证集成测试
- 功能验收
轻量级 BDD 方案
- 需求分析
- 将需求映射为测试用例
- 编写代码以通过测试
- 功能验收
TDD + BDD
- 需求分析
- TDD 测试驱动开发
- 编写单元测试
- 编写代码以使测试通过
- 编写集成测试验证功能需求
BDD 的核心是关注功能需求是否正确,所以先写测试后写测试都可以,但是通常情况下先写测试有助于对需求的理解,从而朝着正确的目标前进。
Vue 中的 BDD 技术栈
- Jest + Vue Test Utils
- 可以做单元测试
- 也可以做集成测试
- Jest + Vue Testing Library
- 只能做集成测试
配置测试环境
继续使用 TDD 案例创建的项目,配置集成测试环境。
- 约定将所有的功能测试模块文件放到
/tests/feature
目录中(feature:功能、特性) - 配置
npm scripts
脚本运行功能测试,指定测试文件匹配规则
"scripts": {
...
"test:unit": "vue-cli-service test:unit",
"coverage": "vue-cli-service test:unit --coverage",
"test:feature": "vue-cli-service test:unit --testMatch **/tests/feature/**/*.spec.[jt]s?(x)"
},
- 可以修改 ESLint 配置忽略 Jest 代码监测
module.exports = {
...
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)',
'**/tests/feature/**/*.spec.{j,t}s?(x)',
],
env: {
jest: true,
},
},
],
}
编写测试用例:
// tests\feature\TodoApp.spec.js
test('a', () => {
console.log('Hello World')
})
运行测试:
npm run test:feature
PS:一般集成测试的测试用例编写需要一定时间,在这个过程中没必要实时(
--watch
)的运行测试,可以等测试用例编写完成后再运行测试。
需求分析及编写功能测试用例
可以通过 describe
将测试用例分组,以功能命名。
test
描述可以是给定的行为和结论。
例如:
// tests\feature\TodoApp.spec.js
describe('添加任务', () => {
test('在输入框中输入内容按下回车,应该添加任务到列表中', () => {
})
test('添加任务成功后,输入框内容应该被清空', () => {
})
})
describe('删除任务', () => {
test('点击任务项中的删除按钮,任务应该被删除', () => {
})
})
describe('切换所有任务的完成状态', () => {
test('选中切换所有按钮,所有任务应该变成已完成', () => {
})
test('取消选中切换所有按钮,所有任务应该变成未完成', () => {
})
test('当所有任务已完成的时候,全选按钮应该被选中,否则不选中', () => {
})
})
下面开始编写实现细节。
添加任务到列表中
// tests\feature\TodoApp.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import TodoApp from '@/components/TodoApp'
const linkActiveClass = 'selected'
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter({
linkActiveClass,
})
/** @type {import('@vue/test-utils').Wrapper} */
let wrapper = null
beforeEach(() => {
wrapper = mount(TodoApp, {
localVue,
router,
})
})
describe('添加任务', () => {
test('在输入框中输入内容按下回车,应该添加任务到列表中', async () => {
// 获取输入框
const input = wrapper.findComponent('input[data-testid="new-todo"]')
// 输入内容
const text = 'Hello World'
await input.setValue(text)
// 按下回车
await input.trigger('keyup.enter')
// 断言:内容被添加到列表中
expect(wrapper.findComponent('[data-testid="todo-item"]')).toBeTruthy()
expect(wrapper.findComponent('[data-testid="todo-text"]').text()).toBe(text)
})
})
从该测试用例的实现可以看到,集成测试只关注功能,不关注内部怎么实现,例如组件细节、自定义事件名称和参数等。
添加任务完成清空文本框
test('添加任务成功后,输入框内容应该被清空', async () => {
// 获取输入框
const input = wrapper.findComponent('input[data-testid="new-todo"]')
// 输入内容
const text = 'Hello World'
await input.setValue(text)
// 按下回车
await input.trigger('keyup.enter')
// 断言:内容被添加到列表中
expect(input.element.value).toBeFalsy()
})
删除单个任务项功能测试
describe('删除任务', () => {
test('点击任务项中的删除按钮,任务应该被删除', async () => {
// 准备测试环境数据
await wrapper.setData({
todos: [
{ id: 1, text: 'eat', done: false },
],
})
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
// 断言:删除之前任务项是存在的
expect(todoItem.exists()).toBeTruthy()
// 找到任务项的删除按钮
const delButton = wrapper.findComponent('[data-testid="delete"]')
// 点击删除按钮
await delButton.trigger('click')
// 断言:删除按钮所在的任务项应该被移除
expect(todoItem.exists()).toBeFalsy()
})
})
切换单个的任务完成状态
describe('切换单个任务的完成状态', () => {
test('选中任务完成状态按钮,任务的样式变成已完成状态', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: false },
],
})
const todoDone = wrapper.findComponent('[data-testid="todo-done"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
// 断言:初始未选中
expect(todoDone.element.checked).toBeFalsy()
// 断言:初始没有完成样式
expect(todoItem.classes('completed')).toBeFalsy()
// 选中任务项的复选框
await todoDone.setChecked()
// 断言结果
expect(todoDone.element.checked).toBeTruthy()
expect(todoItem.classes('completed')).toBeTruthy()
})
test('取消选中任务完成状态按钮,任务的样式变成未完成状态', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: true },
],
})
const todoDone = wrapper.findComponent('[data-testid="todo-done"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
expect(todoDone.element.checked).toBeTruthy()
expect(todoItem.classes('completed')).toBeTruthy()
await todoDone.setChecked(false)
expect(todoDone.element.checked).toBeFalsy()
expect(todoItem.classes('completed')).toBeFalsy()
})
})
切换所有任务完成状态
describe('切换所有任务的完成状态', () => {
test('选中切换所有按钮,所有任务应该变成已完成', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: false },
{ id: 2, text: 'eat', done: false },
{ id: 3, text: 'sleep', done: true },
],
})
const toggleAll = wrapper.findComponent('[data-testid="toggle-all"]')
const todoDones = wrapper.findAllComponents('[data-testid="todo-done"]')
expect(toggleAll.element.checked).toBeFalsy()
await toggleAll.setChecked()
expect(toggleAll.element.checked).toBeTruthy()
// 注意:todoDones 不是真正的数组,不能用 forEach 遍历
for (let i = 0; i < todoDones.length; i++) {
expect(todoDones.at(i).element.checked).toBeTruthy()
}
})
test('取消选中切换所有按钮,所有任务应该变成未完成', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: true },
{ id: 2, text: 'eat', done: true },
{ id: 3, text: 'sleep', done: true },
],
})
const toggleAll = wrapper.findComponent('[data-testid="toggle-all"]')
const todoDones = wrapper.findAllComponents('[data-testid="todo-done"]')
expect(toggleAll.element.checked).toBeTruthy()
await toggleAll.setChecked(false)
expect(toggleAll.element.checked).toBeFalsy()
for (let i = 0; i < todoDones.length; i++) {
expect(todoDones.at(i).element.checked).toBeTruthy()
}
})
test('当所有任务已完成的时候,全选按钮应该被选中,否则不选中', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: true },
{ id: 2, text: 'eat', done: false },
{ id: 3, text: 'sleep', done: false },
],
})
const toggleAll = wrapper.findComponent('[data-testid="toggle-all"]')
// 注意:findAll 已废弃,未来版本将移除,官方推荐的 findAllComponents 当前项目使用的版本还不支持 CSS Selector
const todoDones = wrapper.findAll('[data-testid="todo-done"]')
expect(toggleAll.element.checked).toBeFalsy()
for (let i = 0; i < todoDones.length; i++) {
todoDones.at(i).setChecked()
}
await wrapper.vm.$nextTick()
expect(toggleAll.element.checked).toBeTruthy()
// 取消选中任意任务项
await todoDones.at(0).setChecked(false)
// 断言:全选应该取消选中
expect(toggleAll.element.checked).toBeFalsy()
})
})
编辑任务功能测试
describe('编辑任务', () => {
beforeEach(async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: false },
],
})
})
test('双击任务项文本,应该获得编辑状态', async () => {
const todoText = wrapper.findComponent('[data-testid="todo-text"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
// 双击之前确认任务项不是编辑状态
expect(todoItem.classes('editing')).toBeFalsy()
// 双击任务项文本
await todoText.trigger('dblclick')
// 双击之后,任务项应该获得编辑状态
expect(todoItem.classes('editing')).toBeTruthy()
})
test('修改任务项文本按下回车后,应该保存修改以及取消编辑状态', async () => {
const todoText = wrapper.findComponent('[data-testid="todo-text"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 双击文本获得编辑状态
await todoText.trigger('dblclick')
// 修改任务项文本
const text = 'hello'
await todoEdit.setValue(text)
// 回车保存
await todoEdit.trigger('keyup.enter')
// 断言:任务项文本被修改
expect(todoText.text()).toBe(text)
// 断言:任务项的编辑状态取消了
expect(todoItem.classes('editing')).toBeFalsy()
})
test('清空任务项文本,保存编辑应该删除任务项', async () => {
const todoText = wrapper.findComponent('[data-testid="todo-text"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 双击文本获得编辑状态
await todoText.trigger('dblclick')
// 清空任务项文本
await todoEdit.setValue('')
// 回车保存
await todoEdit.trigger('keyup.enter')
// 断言:任务项应该被删除
expect(todoItem.exists()).toBeFalsy()
})
test('修改任务项文本按下 ESC 后,应该取消编辑状态以及任务项文本保持不变', async () => {
const todoText = wrapper.findComponent('[data-testid="todo-text"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 双击文本获得编辑状态
await todoText.trigger('dblclick')
// 获取原始内容
const originText = todoText.text()
// 修改任务项文本
await todoEdit.setValue('hello')
// ESC 取消
await todoEdit.trigger('keyup.esc')
// 断言:任务项还在
expect(todoItem.exists()).toBeTruthy()
// 断言:任务项文本不变
expect(todoText.text()).toBe(originText)
// 断言:任务项的编辑状态取消了
expect(todoItem.classes('editing')).toBeFalsy()
})
})
清除所有已完成任务项
describe('删除所有已完成任务', () => {
test('如果所有任务已完成,清除按钮应该展示,否则不展示', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: false },
{ id: 2, text: 'eat', done: false },
{ id: 3, text: 'sleep', done: false },
],
})
const clearCompleted = wrapper.findComponent('[data-testid="clear-completed"]')
expect(clearCompleted.exists()).toBeFalsy()
const todoDones = wrapper.findAll('[data-testid="todo-done"]')
// 设置某个任务变成完成状态
await todoDones.at(0).setChecked()
await wrapper.vm.$nextTick()
// 断言:清除按钮应该是展示状态
// 注意:使用 `exists()` 时使用已获取的 Wrapper,如果 DOM 状态发生变化,`exists()` 可能不会跟着变化,建议重新获取 Wrapper
expect(wrapper.findComponent('[data-testid="clear-completed"]').exists()).toBeTruthy()
})
test('点击清除按钮,应该删除所有已完成任务', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: true },
{ id: 2, text: 'eat', done: false },
{ id: 3, text: 'sleep', done: true },
],
})
const clearComplelted = wrapper.findComponent('[data-testid="clear-completed"]')
// 点击清除按钮
await clearComplelted.trigger('click')
const todoItems = wrapper.findAll('[data-testid="todo-item"]')
expect(todoItems.length).toBe(1)
expect(todoItems.at(0).text()).toBe('eat')
expect(wrapper.findComponent('[data-testid="clear-completed"]').exists()).toBeFalsy()
})
})
展示所有未完成任务数量
describe('展示所有未完成任务数量', () => {
test('展示所有未完成任务数量', async () => {
await wrapper.setData({
todos: [
{ id: 1, text: 'play', done: true },
{ id: 2, text: 'eat', done: true },
{ id: 3, text: 'sleep', done: true },
],
})
const getDoneTodosCount = () => {
const dones = wrapper.findAll('[data-testid="todo-done"]')
let count = 0
for (let i = 0; i < dones.length; i++) {
if (!dones.at(i).element.checked) {
count++
}
}
return count
}
// 断言未完成任务的数量
const todoDonesCount = wrapper.findComponent('[data-testid="done-todos-count"]')
expect(todoDonesCount.text()).toBe(getDoneTodosCount().toString())
// 切换一个任务的状态,再断言
const dones = wrapper.findAll('[data-testid="todo-done"]')
await dones.at(0).setChecked(false)
expect(todoDonesCount.text()).toBe(getDoneTodosCount().toString())
// 删除任务项,再断言
await wrapper.findComponent('[data-testid="delete"]').trigger('click')
expect(todoDonesCount.text()).toBe(getDoneTodosCount().toString())
})
})
数据筛选功能测试
给导航链接添加 data-testid
:
<ul class="filters">
<li>
<router-link to="/" data-testid="link-all" exact>All</router-link>
</li>
<li>
<router-link to="/active" data-testid="link-active">Active</router-link>
</li>
<li>
<router-link to="/completed" data-testid="link-completed">Completed</router-link>
</li>
</ul>
describe('数据筛选', () => {
const todos = [
{ id: 1, text: 'play', done: true },
{ id: 2, text: 'eat', done: false },
{ id: 3, text: 'sleep', done: true },
]
const filterTodos = {
all: () => todos,
active: () => todos.filter(t => !t.done),
completed: () => todos.filter(t => t.done),
}
beforeEach(async () => {
await wrapper.setData({
todos,
})
})
test('点击 all 链接,应该展示所有任务,并且 all 链接应该高亮', async () => {
// vue router 跳转重复导航时会返回 rejected Promise,这里捕获一下避免命令行中显示错误提示
router.push('/').catch(() => {})
// 路由导航后要等待视图更新
await wrapper.vm.$nextTick()
expect(wrapper.findAll('[data-testid="todo-item"]').length).toBe(filterTodos.all().length)
expect(wrapper.findComponent('[data-testid="link-all"]').classes()).toContain(linkActiveClass)
})
test('点击 active 链接,应该展示所有未完成任务,并且 active 链接应该高亮', async () => {
router.push('/active').catch(() => {})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('[data-testid="todo-item"]').length).toBe(filterTodos.active().length)
expect(wrapper.findComponent('[data-testid="link-active"]').classes()).toContain(linkActiveClass)
})
test('点击 completed 链接,应该展示所有已完成任务,并且 completed 链接应该高亮', async () => {
router.push('/completed').catch(() => {})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('[data-testid="todo-item"]').length).toBe(filterTodos.completed().length)
expect(wrapper.findComponent('[data-testid="link-completed"]').classes()).toContain(linkActiveClass)
})
})
优化获取 data-testid 的方法
增加获取 Wrapper 的实例方法
beforeEach(() => {
wrapper = mount(TodoApp, {
localVue,
router,
})
// 增加通过 data-testid 获取 Wrapper 的方法
wrapper.findById = id => {
return wrapper.findComponent(`[data-testid="${id}"]`)
}
wrapper.findAllById = id => {
return wrapper.findAll(`[data-testid="${id}"]`)
}
})
// 示例:
describe('添加任务', () => {
test('在输入框中输入内容按下回车,应该添加任务到列表中', async () => {
// 获取输入框
// const input = wrapper.findComponent('input[data-testid="new-todo"]')
const input = wrapper.findById('new-todo')
// 输入内容
const text = 'Hello World'
await input.setValue(text)
// 按下回车
await input.trigger('keyup.enter')
// 断言:内容被添加到列表中
// expect(wrapper.findComponent('[data-testid="todo-item"]')).toBeTruthy()
// expect(wrapper.findComponent('[data-testid="todo-text"]').text()).toBe(text)
expect(wrapper.findById('todo-item')).toBeTruthy()
expect(wrapper.findById('todo-text').text()).toBe(text)
})
})
全局使用
官方文档:Configuring Jest
当前只是在当前测试文件中添加了方法,要想在全局使用,可以让代码在运行每个测试文件之前执行,通过在 Jest 的配置文件中配置 setupFiles
或 setupFilesAfterEnv
。
它们的作用是指定在运行每个测试文件之前执行的代码文件,两者的区别只是 setupFiles
中不能编写测试用例(例如不能使用 test
、expect
等 API),而 setupFilesAfterEnv
可以,
// jest.config.js
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
setupFilesAfterEnv: ['./jest.setup.js'],
}
新建 setup 文件添加实例方法:
// jest.setup.js
import { Wrapper } from '@vue/test-utils'
// 1. 通过 Wrapper 原型添加实例方法
// 2. 使用 function 而不是箭头函数,保证 this 指向
Wrapper.prototype.findById = function (id) {
return this.findComponent(`[data-testid="${id}"]`)
}
Wrapper.prototype.findAllById = function (id) {
return this.findAll(`[data-testid="${id}"]`)
}
注释掉测试文件中添加实例方法的代码,重新运行测试。