回顾:cypress 总结、质量左移指南

我们经常有听到 TDD、写单测等等,那么跟 UI 强关联的组件的测试应该怎么做?

本文使用 Cypress 框架,通过一个组件示例,一步步进行实践,尝试把 TDD 在前端落地。

环境搭建

Cypress 是一个完整易用的测试框架,我们可以使用 Cypress 进行 e2e 测试、集成测试、单元测试
用来实现组件测试有着几大优势

支持真实的浏览器运行环境,直接使用 web 浏览器上的开发工具直接调试

在运行测试的时候,会获取快照,记录了测试执行过程的每一步细节

运行速度非常快,基本可以与浏览器内容实时同步

在 Cypress 官方的组件测试示例仓库 cypress-component-examples 中,选择 vite-vue 作为初始化的模板

1
实际业务中已有的项目可以参考 Vite Based Projects (Vue, React) 中说明进行接入

在这里插入图片描述

可以看到,有一个 HelloWorld.spec.js 的测试文件

首先开始安装、运行,看看是什么效果

1
2
3
4
5
pnpm install

# 在浏览器打开测试用例集的界面
pnpm cypress open-ct

在这里插入图片描述

从 Cypress 自带的测试用例预览界面可以看到,用例已经正常运行通过了,接下来进入正文

由于后续示例代码使用 ts 编写,这里先添加 @vitejs/plugin-vue-jsx 插件

1
2
3
4
5
6
7
8
9
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx()],
});
1
2
3
4
5
// cypress.json
{
"testFiles": "**/*.spec.[jt]s",
"componentFolder": "src"
}

组件测试

以一个 Rate 评分组件为例

在这里插入图片描述

  1. 基础显示
  2. 支持点击选中
  3. 支持传入初始值选中
  4. 支持 hover 高亮

受限篇幅,本文只支持以上功能

测试驱动开发

测试驱动开发,即 TDD,它的规则很简单,可以归纳为下面三条:

  • 先编写一个因为缺乏实现代码而运行失败的测试,然后编写实现代码。
  • 只允许编写一个刚好失败的测试 - 编译失败也算失败。
  • 只允许编写刚好能使当前失败测试通过的实现代码。

遵循 TDD 三原则,意味着你的每一行实现代码都是有测试保证的,先有的测试,才有的你那一行恰好可以通过的实现代码。你的测试是完备的,你有信心部署你测试全过的代码,这些测试告诉我们,我们的系统是可靠、可部署的。
通过上述的三原则,从第一个测试用例开始

编写第一个测试用例

在 components 下新建 rate 目录存放相关代码实现以及测试用例

在这里插入图片描述

如上图所示,由于我们还没开始写 Rate 组件的实现,现在导入组件是编译报错的状态

假设初始化会渲染 5 个类为 mio-rate-item 的元素,那么此时写下第一个测试用例代码

1
2
3
4
5
6
7
8
9
10
import { mount } from '@cypress/vue';
import Rate from './index';

describe('rate component', () => {
it('should render 5 item elements', () => {
mount(Rate);

cy.get('.mio-rate-item').should('have.length', 5);
});
});

在这里插入图片描述

打开浏览器用例运行界面,可以看到左侧的用例列表多出来了 src/component/rate/rate.spec.ts ,且编译摆错了。

针对第一个用例编写实现代码

为了使刚才写的第一个用例通过,回想之前提到的三原则,这次只针对性的写这个用例对应的实现代码

创建一个容器,然后渲染 5 个类名为 mio-rate-item 的子元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// rate/index.tsx
import { defineComponent } from 'vue';
import { renderIcon } from './icon';
import './index.css';

const getNumberList = (num: number) => {
return Array.from({ length: num }).map((_, i) => i + 1);
};

const useRateClasses = () => {
return ['mio-rate-item'];
};

export default defineComponent({
name: 'Rate',

setup() {
const renderRateItem = () => (
<div class={useRateClasses()}>{renderIcon()}</div>
);
const renderRate = () => (
<div class="mio-rate">{getNumberList(5).map(() => renderRateItem())}</div>
);

return () => renderRate();
},
});

在这里插入图片描述

查看用例运行界面可以发现用例已经运行通过了,右侧的界面成功的渲染了 5 个 ⭐️

第二个测试用例

根据上述的步骤继续,这次需要支持点击选择功能

假设我们点击后会给选中的子元素加上 .is-active ,那么自然而然写下测试用例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { mount } from '@cypress/vue';
import Rate from './index';

describe('rate component', () => {
it('should render 5 item elements', () => {
mount(Rate);

cy.get('.mio-rate-item').should('have.length', 5);
});

it('should be highlighted when the element is clicked', () => {
mount(Rate);

cy.get('.mio-rate-item.is-active').should('have.length', 0);

// 点击第 3 个子元素
cy.get('.mio-rate-item:nth-of-type(3)').click();

// 带有 .is-active 的子元素应该有 3 个
cy.get('.mio-rate-item.is-active').should('have.length', 3);
});
});

在这里插入图片描述

查看运行界面,可以看到用例运行状态是失败的,右侧渲染的组件中也没有高亮

然后需要在对应的组件文件进行实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { defineComponent, ref, Ref } from 'vue';
import { renderIcon } from './icon';
import './index.css';

const getNumberList = (num: number) => {
return Array.from({ length: num }).map((_, i) => i + 1);
};
function useRateClasses({
currentValue,
index,
}: {
currentValue: Ref<number>,
index: number,
}) {
return ['mio-rate-item', currentValue.value >= index ? 'is-active' : ''];
}

export default defineComponent({
name: 'Rate',

setup() {
const currentValue = ref(0);
const onClickItem = (index: number) => (currentValue.value = index);

const renderRateItem = (index: number) => (
<div
class={useRateClasses({ currentValue, index })}
onClick={() => onClickItem(index)}
>
{renderIcon()}
</div>
);
const renderRate = () => (
<div class="mio-rate">
{getNumberList(5).map((i) => renderRateItem(i))}
</div>
);

return () => renderRate();
},
});

在这里插入图片描述

切换到用例运行的面板,可以看到用例已经执行成功了

点击步骤可查看组件的中间状态,如果中间出现问题,还可以打开 chrome devtools 去调试

重复上述步骤

根据之前提到的 TDD 三原则,重复的进行 写用例 -> 写实现代码 -> 调试通过 -> 重构/优化设计 -> 写用例 -> … 的过程。

  • v-model 功能

支持传入初始值选中显示

  • props 用例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { mount, mountCallback } from '@cypress/vue';
import Rate from './index';

describe('rate component', () => {
// ...
describe('v-model value', () => {
// 当前 describe 作用域下每个用例执行前进行 mount
beforeEach(
mountCallback(Rate, {
propsData: {
modelValue: 3,
},
})
);

it('should work when set props value', () => {
cy.get('.mio-rate-item.is-active')
.should('have.length', 3)
.then(() => {
// 参考 @vue/test-utils 的 wrapper api
Cypress.vueWrapper.setProps({
modelValue: 4,
});
cy.get('.mio-rate-item.is-active').should('have.length', 4);
});
});
});
});
  • Props 逻辑实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default defineComponent({
name: 'Rate',
props: {
modelValue: {
type: Number,
default: 0,
},
},

setup(props) {
const currentValue = ref(props.modelValue);

watch(
() => props.modelValue,
(value) => {
currentValue.value = value;
}
);
// ...
},
});
  • emit 用例
1
2
3
4
5
6
7
8
9
10
it('should be emit input when the element is clicked', () => {
cy.get('.mio-rate-item.is-active').should('have.length', 3);

cy.get('.mio-rate-item:nth-of-type(2)')
.click()
.then(() => {
expect(Cypress.vueWrapper.emitted()['update:modelValue'].length).to.eq(1);
expect(Cypress.vueWrapper.emitted()['update:modelValue'][0][0]).to.eq(2);
});
});
  • emit 实现
1
2
3
4
5
6
7
8
9
setup(props, { emit }) {
const currentValue = ref(props.modelValue);

watch(currentValue, (value) => {
emit('update:modelValue', value);
});
// ...
})

悬浮高亮功能

  • 用例
1
2
3
4
5
6
7
8
9
10
11
it('should be highlighted when hover', () => {
mount(Rate);

cy.get('.mio-rate-item:nth-of-type(3)').trigger('mouseenter');

cy.get('.mio-rate-item.is-active').should('have.length', 3);

cy.get('.mio-rate-item:nth-of-type(3)').trigger('mouseleave');

cy.get('.mio-rate-item.is-active').should('have.length', 0);
});
  • 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ...
function useRateClasses({
currentValue,
currentOverValue,
index,
}: {
currentValue: Ref<number>,
currentOverValue: Ref<number>,
index: number,
}) {
return [
'mio-rate-item',
(currentOverValue.value || currentValue.value) >= index ? 'is-active' : '',
];
}

// setup ...
const currentOverValue = ref(0);
const renderRateItem = (index: number) => (
<div
class={useRateClasses({ currentValue, currentOverValue, index })}
onClick={() => onClickItem(index)}
onMouseenter={() => (currentOverValue.value = index)}
onMouseleave={() => (currentOverValue.value = 0)}
>
{renderIcon()}
</div>
);

运行效果

在这里插入图片描述

可以看到,用例已经全部运行通过了

重构

完成了上述的过程是否就已经结束了呢?其实还漏了一个重要的步骤,那就是重构。
如果有任何重复的逻辑、比较冗余的代码,重构可以消除重复并提高表达能力(减少耦合,增加内聚力)。
再次运行测试验证重构是否引入新的错误。如果没有通过,很可能是在重构时犯了一些错误,需要立即修复并重新运行,直到所有测试通过。
以上述实现的 v-model 功能为例,在封装组件的时候,这类功能是比较常见的,那么这部分是否可以抽离出一个单独的函数来维护?先简单来实践一下
首先封装一个名为 useVModel 的函数,将 v-model 所涉及到的关联逻辑放进来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const useVModel = <T extends { modelValue: T['modelValue'] }>(props: T) => {
const { emit } = getCurrentInstance();

const proxy = ref(props.modelValue);

watch(proxy, (value) => {
emit('update:modelValue', value);
});

watch(
() => props.modelValue,
(value) => {
proxy.value = value as UnwrapRef<T['modelValue']>;
},
);

return proxy;
};

替换完成后,再次执行刚才写的测试用例,正常通过。
通过这个重构操作,上述封装的 useVModel 就可以在其他地方进行复用,也简化了在业务上的调用逻辑。
总的来说,有了之前的测试用例基础,重构也有对应的质量保障,且重构能够 消除重复设计,优化设计结构 ,对于整体的代码质量,可维护性与可扩展性都有了提升。

编写总结

测试驱动开发 要求每次只添加一个行为,先写一个失败的测试,然后写出恰好能使这个测试通过的实现代码。
你写出的每一个测试都是一份代码示例,如何调用 API,如何创建某个对象。测试已经有了超过 90% 使用场景覆盖,而且这可以立即发现错误,去调试、修复它。如果先写一大堆实现代码,再来补测试,这时候已经先入为主,你很难发现自己的代码有什么问题。
在此过程中穿插的重构,也会让我们不断的思考如何实现好的代码,提升整体的代码质量。
另外,在工具层面,Cypress 新的组件测试器对测试组件有着很好的支持,而且对于 vite 项目来说,也有比较好的集成,是测试在浏览器中呈现的任何内容理想选择。

质量左移

Cypress 给项目带来的价值:

  • 缺陷前移
  • 质量后盾
  • 提升个人能力

缺陷前移

什么是缺陷前移?
简单理解:缺陷前移就是在前期阶段做详尽的分析,采取好的措施去尽早地发现不合理的地方,尽早地暴露问题,促使团队更早地识别修复缺陷,这样后面出问题的几率就会越低,效能越好。编码阶段的措施可以是完成业务开发的同时产出测试用例,尽可能保证所有的代码都被测试到,所有的业务流程都能被测试用例覆盖到。
为什么要做缺陷前移?

在这里插入图片描述

如上图,项目修复 Bug 流程简化版所示,前期修改 Bug 很简单,开发自检出来有 Bug,自己改代码修复,然后上库代码即可。后期修改 Bug 就会增加很多很多前期没有的环节了:

开发需要根据测试人员描述去复现 Bug,可能需要自己构建复现的环境,Bug 可能是必现的,可能是偶现的,如果复现不出来,还需要找测试人员沟通,让测试人员复现。从这一点可以看出,前端测试属于正向追溯,开发人员可以直接把问题复现,直接看到用例的代码执行细节,方便进行问题定位。而后期修改 Bug 属于逆向追溯,盲目性和工作量都是大大增加的。
Bug 解决得慢、后期 Bug 数量多都可能会阻塞项目进度,导致项目延期。
修改好 Bug 后,开发人员还要提供代码改动点,评估改动关联影响,给予测试人员测试建议。
修改好 Bug 后,还需要分析 Bug 产生的原因,开发人员需要对 Bug 负责任,转交给项目负责人审核。
测试人员需要重新构建新环境进行 Bug 修复验证,需要对 Bug 进行测试发散,如果 Bug 没改好,或者改动引发了新问题,还需要让开发人员重新修改 Bug,重新走一系列改 Bug 流程。
……

Bug 越到后面抛出,项目中人员所付出的时间也就越多,项目所承担的风险也就越大。基于 Cypress 做缺陷前移,在开发阶段写组件测试、e2e 测试,在前期就把问题测出来,而不是等到后期才测出,缺陷前移率越高,项目的健康度、项目的能效也就越好。

质量后盾

开发代码上库前一般会有 CodeReview、代码走读等机制去严格把关代码的质量,CodeReview、代码走读机制都是以人为主体的保障,CodeReview 的效果好坏会受人的当前状态和人的技术能力水平影响。比如一个人粗枝大叶,看代码不够细心,或者这个人感冒发烧、失恋、基金亏损导致头脑不清醒,CodeReview 的效果都会大打折扣,项目的质量不能仅仅通过 CodeReview 去保障,而前端测试是以机器为主体的保障,机器不会受情绪影响。
相信你或者你身边的同事都遇到过,编写代码改了好几个文件,然后上库代码的时候,忘记提交其中的一个文件,执行报错影响基础功能,合代码时没发现,等到测试人员反馈,等到被投诉时才知道自己犯了这么一个低级错误。假设 CI 流水线上有前端测试去保障基础业务功能,这种低级错误一般都可以在合代码跑流水线的时候暴露出来了。
针对改代码漏测场景、改老问题引发新问题,可以通过提高前端测试的覆盖率来保障基本的业务流程跑通、业务功能不出问题。
组件测试可以反映出你的组件封装得好不好用,如果你的组件不好用,测试代码写起来肯定也是偏向复杂的,上库代码审核的时候,CodeReview 的人也可以通过你写的测试用例理解你的代码,拥有前端测试用辅助的 CodeReview 效果肯定会比没有的好。
项目迭代积累下来的测试用例,可以用来支撑未来模块的重构优化、项目的架构演进。
升级第三方库对项目的影响面广,比如升级vue@2.5vue@2.6,如果有问题,开发人员是比较难提前感知到的。

提升个人能力

懂前端去做前端测试是一个优势,而测试与前端属于不同的领域,需要自己折腾,自己学习很多东西:

项目引入测试框架,Cypress 集成到 CI 流水线上,肯定会遇到很多问题,比如内网代理,测试框架与技术栈的冲突……在解决这些问题的同时锻炼了你的解决问题能力
Cypress 有自己的 API 封装、有自己的插件,如何使用 Cypress 编写用例?如何用 Cypress 测试一个组件?如何测试 TypeScript 的类型声明?你在学习新知识、实践新知识的同时也锻炼了你的学习上手能力
如何基于 UI 组件库、基于项目去封装好用的测试 API?如果做好大型项目的 e2e 测试和组件测试?这可以锻炼你的设计能力
测试框架的选型如何做选择?选 Jest 还是 Cypress?这可以考察锻炼你的预研能力

当你把 Cypress 很好地集成进项目里面时,想必那时候你的能力也成长了许多。

如何使用 Cypress 做前端测试

  • 测试设计
  • 实践建议
  • 调试技巧

测试设计

这里提供一种可行的测试规划设计:

  • 版本前期做到测试用例覆盖业务的主流程,版本后期持续沉淀更多的测试用例,比如对一些边界场景下产生的 Bug,补充相应的测试用例。

  • 对于迭代很快、生命周期又短的页面(如商家活动页、运营页),允许不做前端测试。图表组件一般交互性不强,代码不会很复杂,也可以不对图表组件做前端测试。

  • 前端开发者在写测试用例时,需要尽可能不去依赖公共组件的 DOM 结构、内部封装细节,假设你写了 200 条测试用例依赖了弹窗组件的 DOM 结构细节,有一天组件库升级修改了弹窗组件的 DOM 结构,到时候你就需要手动修改这 200 条测试用例,改起来十分痛苦。得益于 Cypress 的 command 特性,开发者可以对公共组件库的组件行为、组件断言进行二次封装,做到开发者在写测试用例时尽可能使用封装好的 command,不需要去关注组件的内部细节。

  • 大粒度测试结合小粒度测试,大粒度测试业务流程,小粒度测试相对独立的组件。

我们在写测试用例可能会有一种感觉,就是你发现要测的东西测重复了,比如你在子组件里测了这个功能,然后你在父组件里又把这个功能测了一遍。为了更好的测试效率和测试效果,我们要对前端测试区分粒度。
大粒度通常是页面级别的,干扰项、依赖项多,依赖接口数据,依赖他人的组件。干扰项和依赖项都需要去做 mock。运行时间上,大粒度测试耗时会比较久。在业务没有变化的情况下,假设我们对代码进行重构,我们是不需要去改动大粒度测试的测试用例代码的。大粒度测试的标题可以与需求点、QA 测试点一一对应起来,格式可以参考:test(‘已知 xxx,进行 xxx,期望 xxx’, () => { /** 用例代码 */ });。
小粒度测试通常是针对单一组件、单一函数、类级别的,干扰项很少。小粒度测试运行速度很快,如果模块重构,相关组件的测试代码很可能需要被一起重构掉或者重写掉。小粒度测试的标题对应组件的实现细节:test(‘prop username work’, () => { /** 用例代码 */ });。

实践建议

  1. 对于组件测试,不需要测试组件内部实现的 API,使用组件时内部 API 对开发人员是隐藏的,我们应该测试组件的使用功能,vue 组件一般是测试传入的 props、slots,测试对外暴露的 API,像浮层类 UI 组件可能还需要测试父组件销毁时,浮层类组件是否跟着销毁。如果项目对无障碍性有要求,还需要测试组件的无障碍性。

  2. 不要把所有的单测代码都塞在一个测试用例里面,这样会降低阅读性和维护性,而且还会降低单测运行速度,适当拆分成多个可以做到并发运行单测。

  3. 像 Math.random、new Date 等尽量不要在单测中使用,因为这些 API 带有不确定性,应该使用固定的具体值,我们要尽可能保证测试用例不管运行多少次,每次运行它的前置条件的完全一致的。

  4. 试代码不完善导致测试是通过的,但是功能是有 Bug 的。

带来的问题

Cypress 接入项目是会增加使用成本的,不同的测试框架,API 都存在差异,前端开发人员需要学习上手 Cypress 框架的框架理念、框架 API。
在编码阶段让开发人员写测试用例会增加开发人员的工作量,但是不会加工资,开发人员评估工作量任务排期的时候,需要把前端测试的工作量也计算进去。
一些防抖节流操作可能导致测试用例偶尔挂掉、偶尔通过,排查这一类问题时费时费力。