“我正在参加「掘金·启航计划」”
背景
最近写了一个虚拟滚动的插件,由于业务逻辑比较复杂,总是容易出现修改了一点代码导致其他功能可能会出现异常的情况。每次更新都需要手动测试下所有功能是否正常,容易出问题也比较浪费时间,所以考虑写下组件测试。
vue官网推荐的组件测试有两种方案:vitest + @testing-library/vue 以及 cypress。但 vitest + @testing-library/vue是模拟组件渲染,并没有浏览器环境,而我的组件中涉及到较多依赖浏览器环境的api(e.g. 监听滚动事件,intersectionOberser等),所以选择用第二种方案 cypress。
本文主要是记录和总结使用 cypress 的基本使用。
前言
其实我在写完后又重构了一次,我想在文章开头先说下这次重构的原因:
第一次编写插件的测试代码,其实我是直接渲染了项目中的 example(用于给开发人员展示功能的在线demo) 来进行测试的:
1、example中有各种调用组件功能的input和button;
2、修改input输入,点击各种按钮后,断言渲染是否符合预期;
说实话也能达到我测试的目的,但还是有一些问题:
1、这更像是一个只有一个组件的e2e测试,而不是组件测试;
2、跟example耦合在一起会给后面带来一些麻烦,即使我在拷贝一份出来,其实这样的测试代码还是不好维护(花了部分精力在用户操作上,e.g. 输入/清空/点击/跳转等);
所以我进行了重构,重构的内容主要如下:
1、不再调用 example 或者拷贝一份类似的代码。而是直接引用需要被测试的组件进行 mount;
2、mount 之后获取到组件的实例,提供给所有测试套件调用组件的实例方法(主要是组件中的 expose 的方法是否正常执行,以及调整props是否正常渲染);
安装
关于 安装/配置/创建测试任务 这些基本都一样:
// 安装
pnpm install -D cypress
// 配置启动脚本
"test": "cypress open --component --browser chrome" // 通过 chrome 启动 component 测试
启动后是一个可视化的操作界面,直接创建测试文件即可,创建完成后即可编写测试代码:
describe('测试套件', () => {
// 可以在执行前设置 before/beforeEach/after/afterEach 等任务,执行每个测试单元(执行前/之前后)执行的任务
it('测试用例', () => {
// 设置测试用例
})
})
Commands
cypress 是函数链式调用的风格,e.g.
// 点击一个按钮后,检查按钮内容是否正确渲染出vb
cy.get(button).click().should('have.value', 'vb')
这里使用了 get/click/should 等 Command. Command 是 cypress 的重要内容之一.主要包含了以下内容:
1、每个 Command 调用后都会返回包装后的 Chainer 对象,所以可以进行链式调用;
2、重试机制;
3、包含了断言;
因为这里的内容比较重要,而且也是跟其他测试框架区别比较大的地方,所以针对这三点再详细说下;
Chainer 对象
Chainer 是包含所有 command 的对象。并且是一个通过 bluebird 封装过的 Promise 对象, Promise 会 resolve 当前调用链中的具体值。
所以我们有两种方式进行断言:
1、直接通过包含断言的 command 进行断言;
2、通过 Promise 获取到具体的值进行断言或缓存下来进行断言, e.g.
// 检查 input 的值是否为 1
cy.get(input).invoke('val').then(val => {
expect(val).should('eq', 1)
})
command 自动重试
在设置时间(默认 4000ms, 可自定义)内,不断尝试获取内容,直到获取成功或超时。e.g.
cy.get(btn, {timeout: 3000})
举个例子,我们点击一个按钮后修改名字,需要等待渲染完毕后,获取对应的 DOM 节点断言名字修改正确:
// 伪代码,点击->修改数据->等待渲染完成->断言是否修改正确
document.querySelector('button').click()
function clickBtn() {
data.name = 'vb'
}
await sleep(100)
expect(document.querySelector('.name').text).equal('vb')
可以看出来,其实 sleep(100) 是一个玄幻的值,绝大部分情况下应该是已经渲染完成了,但不是绝对的,算是一种妥协。
cypress 尝试做了自己的优化,在获取不到值且在超时时间内进行自动重试。
断言(常用)
cypress几乎每个 command 都有默认的断言功能:
Many commands have a default, built-in assertion, or rather have requirements that may cause it to fail without needing an explicit assertion you've added.
其实这跟我们平时的理解有些区别,e.g.
cy.get('.button') // 这也是一个断言
但这不影响我们的测试逻辑,一般来说我们通过 cypress 的 command(e.g. get/find)获取到 DOM 对象后,需要获取这个对象/DOM对象的属性进行断言,一般有两种方式:
1、通过 should/and 命令获取 对象/DOM属性值 进行断言;
// 断言 li 标签的长度为10
cy.get('li').should('have.length', 10)
2、通过 its/invoke 获取值缓存之后再进行断言;
// 断言 li 标签长度为10
cy.get('li').its('length').should('eq', 10)
// 获取 button 标签的内容
cy.get('button').invoke('text').should('eq', '内容')
两者的区别主要在于有些情况下,是需要将值缓存后再获取另外一个 DOM对象 进行对比的。 另外需要注意下 its/invoke 的区别,its 主要是用来获取改对象的属性值(e.g. 该标签的数量,Object的属性值等),invoke只要是用来调用 DOM对象 的属性/方法。
Commands(常用)
这些是我在第一次使用 cypress 测试时一些比较常用的 command,感觉应该可以满足大部分需求了,如果有些特殊的可以查下文档即可:
1、选择器,跟 jquery/querySelector 类似,强大的选择器功能:
cy.get('.className') // 类名选择器
cy.get('tag') // 标签选择器
cy.get('tag').select(3) // 第三个tag
cy.get('[data-testid=button]') // 自定义属性选择器
2、DOM 输入(事件触发,输入等);
cy.get('.className').click() // 触发点击事件
cy.get('input[data-testid=***]').type('value') // 设置 input 内容
3、断言获取 DOM 属性值;
cy.get('input').should('have.class', 'vb') // 检查input是否有className是 vb
cy.get('h1').should('have.text', 'vb') // 检查h1的text内容是否包含 vb
cy.get('input').should('have.value', 'vb') // 检查input内容是否 vb
cy.get('input').should('have.css', 'height', '100px') // 检查css属性 height 是否 100px
stubs && spies && clocks
这三个概念是测试中比较常用且"高级"的概念。
下面来介绍下这三个概念以及使用场景;
stubs
用来替换调用函数的返回值, e.g.
import getUser from 'user'
cy.stub(getUser).returns([name: 'vb']) // 在程序执行期间调用 getUser 将会返回 [{name: 'vb'}
cy.stub(getUser, [name: 'vb']).widthArg('vb').returns() // specify argument
cy.stub(getUser).resolve([{name: 'vb'}]) // return promise
spies
spy主要是侵入函数,目的是检测目标函数是否有执行;
const spy = cy.spy(obj, 'method')
expect(spy).to.be.called
clock
有时候一些定时任务要测试很麻烦,例如一个凌晨1点执行的任务,不太可能要求测试等到1点去测试。
cypress 提供了了 clock 命令可以让我们随意修改时间进行测试。e.g.
cy.visit('http://localhost:3002')
cy.clock() // 默认当前时间
cy.tick(36000) // 1小时后
expect()...
测试实践
先简单组件的逻辑和测试内容,这是一个虚拟渲染的组件,我需要测试(静态组件)挂载后,调用expose的方法(setSourceData/locate/update/add/del)等方法是否正常渲染:
import VirtualList from './../../src/index.vue' // 需要测试的组件
import { VirtualScrollExpose } from './../../src/index.d' // 导出的组件 expose 方法类型声明
import StaticScrollItem from './staticScrollItem.vue' // 用于props传入的静态组件
import { getMessage } from './mock' // 模拟数据接口
// selector
const container = '[data-testid=container]'
describe('test static item', () => {
// mount component and aliase expose function
beforeEach(() => {
cy.viewport(800, 1000)
cy.mount(VirtualList, {
props: {
initDataNum: 40,
ScrollItemComponent: StaticScrollItem,
retainHeightValue: 42
},
ref: 'VirtualList'
}).its('componentVM.$.exposed').as('exposeFn')
})
it('setSourceData', () => {
// set source data
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(10000)))
cy.get('li').should('have.length', 80)
})
it('locate', () => {
// scrollTop should equal to located item's transformY
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(10000)))
cy.then(() => {
cy.get<VirtualScrollExpose>('@exposeFn').then(exposeFn => exposeFn.locate(99))
})
cy.get('li').should('have.length', 80)
cy.get(container).invoke('scrollTop')
.then(scrollTop => {
cy.get('li[data-index="99"]').should('have.css', 'transform', `matrix(1, 0, 0, 1, 0, ${scrollTop})`)
})
})
it('update item index 10', () => {
// content should change
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(100)))
cy.get('li[data-index=10]').find('div').invoke('text').as('updateTestOldContent')
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.update(10, getMessage(1)[0]))
cy.get('li[data-index=10]').find('div').invoke('text')
.then(content => {
cy.get('@updateTestOldContent').should('not.be', content)
})
})
it('delete item index 33', () => {
// 34 should replace 33
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(300)))
cy.get('li').eq(34).invoke('attr', 'data-key').as('deleteTestAfterItem')
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.del(33))
cy.get('li').eq(33).invoke('attr', 'data-key')
.then(key => {
cy.get('@deleteTestAfterItem').should('eq', key)
})
})
it('locate item index 88 and reset 80 length data', () => {
// current data should be refresh(all in current source data)
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(300)))
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.locate(43))
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => exposeFn.setSourceData(await getMessage(45)))
cy.get<VirtualScrollExpose>('@exposeFn').then(async(exposeFn) => {
const list = exposeFn.getData()
cy.get('li').each((item, index) => {
cy.wrap(item).invoke('attr', 'data-key').then(key => {
expect(list.filter(listChild => listChild.nanoid === key)).length(1)
})
})
})
})
})
问题记录
1、typescript 代码检查/打包时如何区分 cypress/example/src 的配置?
我的目录结构大概如下:
|—— cypress // 测试代码
|—— example // example 代码,主要是发布到github.io做一个线上展示
|—— tsconfig.json
|—— src // 组件代码
|—— tsconfig.json
其实这里的代码检查对于 cypress/example/src 都是一样的,区别只是打包的时候不需要包含 example/cypress 即可,我使用的是 vite-plugin-dts 来打包typescript代码,只需要在插件中指定配置文件(创建一个新的tsconfig.build.json)
import dts from 'vite-plugin-dts'
plugins: [
vue(),
dts({
tsConfigFilePath: 'tsconfig.build.json',
outDir: './dist'
})
]
另外需要注意的是 vite cli 在创建项目时,默认的打包命令是 vue-tsc 进行类型检查和类型文件打包,但我们已经使用了 vite-plugin-dts ,所以这里需要删除,否则没有指定配置文件会导致报错:
"build": "vue-tsc --noEmit && vite build" // 删除 vue-tsc --noEmit
2、测试的组件依赖父节点的样式(例如我这里的测试需要设置父节点的高度),如何给父节点添加样式 or 在 mount 时外面嵌套一层自定义的节点?
暂时没有找到相关说明,在 cypress 的 support 目录中直接修改 component.html 可以达到效果,例如我需要设置父元素的固定高度:
<style>
#__cy_vue_root>div {
height: 1000px;
}
</style>
3、cy.wait(time)什么时候应该用?
官网对 cy.wait(time)的定义是 anti-pattern 的,建议不要使用, Unnecessary-Waiting。
Anti-Pattern: Waiting for arbitrary time periods using cy.wait(Number) .
因为 cypress 在断言时,如果错误或者没获取到对象都会自动重试。我暂时没遇到需要使用 cypress.wait() 的情况,后面遇到再补充。
4、使用cypress alias时如何进行类型声明?
暂时没有支持,但可以通过 get command传入类型参数后在prosmise.then中进行调用,关联issue, e.g.
interface User {
name: string
}
cy.wrap({name: 'vb'}).as('user')
cy.get<User>('@user').then(user => {
expect(user.name).to.equal('vb')
})
5、cypress如何遍历节点(e.g. cy.get('li'))?
cy.get('li').each((item, index) => {
cy.wrap(item).invoke('attr', 'data-key').should('eq', 'vb')
})
6、ES Modules cannot be spied or ES Modules cannot be stubbed?
如果需要 stub/spy 的方法是通过 export 方式导出的, e.g.
// utils method
export function fn() {
// dosomething
}
// test
import { fn } from './utils' // can not test
import * as utils from './utils' // test
export default 是没问题。
7、如何在测试过程中修改props?
在 cypress 文档和issue中没有找到有相关的方法,但在 vue-test-unit 中找到了类似的操作,主要是通过组件实例暴露的 setProps 方法进行修改, e.g.
// 获取到组件实例/setProps方法并设置别名
cy.mount(component, {
name: 'vb'
}).as('component')
cy.get('@component').its('setProps').as('setProps')
cy.get('@component').then(component => {
cy.get<Function>('@setProps').then(setProps => {
setProps.call(component, {
name: 'fish'
})
})
})
需要注意两个问题:
1、props中如果是一个对象,需要创建一个新的对象进行赋值。[issue](https://github.com/vuejs/vue-test-utils/issues/761);
2、setProps 函数需要修改 this 指向,指向组件实例;
8、如何查看组件的测试覆盖率?
关于组件测试的覆盖率问题比较麻烦,并且没什么文章进行说明,我另外开一篇文章再进行详细讲述。
参考文档
1、vitest于跟其他的测试框架进行对比: cn.vitest.dev/guide/compa…
2、How to Interact with DOM Elements: docs.cypress.io/guides/end-…
3、Cypress basics: Check attributes, value and text: filiphric.com/cypress-bas…
4、聊一聊 TypeScript 的工程引用:juejin.cn/post/684490…
5、Best Practices: docs.cypress.io/guides/refe…