[cypress + vue3]组件测试

1,617 阅读10分钟

“我正在参加「掘金·启航计划」”

背景

最近写了一个虚拟滚动的插件,由于业务逻辑比较复杂,总是容易出现修改了一点代码导致其他功能可能会出现异常的情况。每次更新都需要手动测试下所有功能是否正常,容易出问题也比较浪费时间,所以考虑写下组件测试。
vue官网推荐的组件测试有两种方案:vitest + @testing-library/vue 以及 cypress。但 vitest + @testing-library/vue是模拟组件渲染,并没有浏览器环境,而我的组件中涉及到较多依赖浏览器环境的api(e.g. 监听滚动事件,intersectionOberser等),所以选择用第二种方案 cypress。
本文主要是记录和总结使用 cypress 的基本使用。

前言

其实我在写完后又重构了一次,我想在文章开头先说下这次重构的原因:
第一次编写插件的测试代码,其实我是直接渲染了项目中的 example(用于给开发人员展示功能的在线demo) 来进行测试的:

1、example中有各种调用组件功能的inputbutton;  
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…