前言
作为前端,我们经常有听到 TDD、写单测等等,那么跟 UI 强关联的组件的测试应该怎么做?
本文使用 Cypress
框架,通过一个组件示例,一步步进行实践,尝试把 TDD 在前端落地。
环境搭建
Cypress
是一个完整易用的测试框架,我们可以使用 Cypress
进行 e2e
测试、集成测试、单元测试
用来实现组件测试有着几大优势
-
支持真实的浏览器运行环境,直接使用web浏览器上的开发工具直接调试
-
在运行测试的时候,会获取快照,记录了测试执行过程的每一步细节
-
运行速度非常快,基本可以与浏览器内容实时同步
在 Cypress
官方的组件测试示例仓库 cypress-component-examples 中,选择 vite-vue 作为初始化的模板
实际业务中已有的项目可以参考 Vite Based Projects (Vue, React) 中说明进行接入
可以看到,有一个 HelloWorld.spec.js
的测试文件
首先开始安装、运行,看看是什么效果
pnpm install
# 在浏览器打开测试用例集的界面
pnpm cypress open-ct
复制代码
从 Cypress
自带的测试用例预览界面可以看到,用例已经正常运行通过了,接下来进入正文
由于后续示例代码使用
ts
编写,这里先添加@vitejs/plugin-vue-jsx
插件
// 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()], }); 复制代码
// cypress.json { "testFiles": "**/*.spec.[jt]s", "componentFolder": "src" } 复制代码
组件测试
以一个 Rate 评分组件为例
功能需求
- 基础显示
- 支持点击选中
- 支持传入初始值选中
- 支持hover高亮
受限篇幅,本文只支持以上功能
测试驱动开发
测试驱动开发,即 TDD,它的规则很简单,可以归纳为下面三条:
- 先编写一个因为缺乏实现代码而运行失败的测试,然后编写实现代码。
- 只允许编写一个刚好失败的测试 - 编译失败也算失败。
- 只允许编写刚好能使当前失败测试通过的实现代码。
遵循 TDD 三原则,意味着你的每一行实现代码都是有测试保证的,先有的测试,才有的你那一行恰好可以通过的实现代码。你的测试是完备的,你有信心部署你测试全过的代码,这些测试告诉我们,我们的系统是可靠、可部署的。
通过上述的三原则,从第一个测试用例开始
编写第一个测试用例
在 components
下新建 rate
目录存放相关代码实现以及测试用例
如上图所示,由于我们还没开始写 Rate
组件的实现,现在导入组件是编译报错的状态
假设初始化会渲染 5 个类为 mio-rate-item
的元素,那么此时写下第一个测试用例代码
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
的子元素
// 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
,那么自然而然写下测试用例代码
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);
});
});
复制代码
查看运行界面,可以看到用例运行状态是失败的,右侧渲染的组件中也没有高亮
然后需要在对应的组件文件进行实现
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
用例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
逻辑实现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
用例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
实现setup(props, { emit }) { const currentValue = ref(props.modelValue); watch(currentValue, (value) => { emit('update:modelValue', value); }); // ... }) 复制代码
-
-
悬浮高亮功能
-
用例
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); }); 复制代码
-
实现
// ... 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
所涉及到的关联逻辑放进来
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;
};
复制代码
在业务中替换使用
- const currentValue = ref(props.modelValue);
-
- watch(currentValue, (value) => {
- emit('update:modelValue', value);
- });
-
- watch(
- () => props.modelValue,
- (value) => {
- currentValue.value = value;
- },
- );
+ const currentValue = useVModel(props);
复制代码
替换完成后,再次执行刚才写的测试用例,正常通过。
通过这个重构操作,上述封装的 useVModel
就可以在其他地方进行复用,也简化了在业务上的调用逻辑。
总的来说,有了之前的测试用例基础,重构也有对应的质量保障,且重构能够 消除重复设计,优化设计结构 ,对于整体的代码质量,可维护性与可扩展性都有了提升。
总结
测试驱动开发 要求每次只添加一个行为,先写一个失败的测试,然后写出恰好能使这个测试通过的实现代码。
你写出的每一个测试都是一份代码示例,如何调用 API,如何创建某个对象。测试已经有了超过 90% 使用场景覆盖,而且这可以立即发现错误,去调试、修复它。如果先写一大堆实现代码,再来补测试,这时候已经先入为主,你很难发现自己的代码有什么问题。
在此过程中穿插的重构,也会让我们不断的思考如何实现好的代码,提升整体的代码质量。
另外,在工具层面,Cypress
新的组件测试器对测试组件有着很好的支持,而且对于 vite
项目来说,也有比较好的集成,是测试在浏览器中呈现的任何内容理想选择。
参考链接
docs.cypress.io/ 官方文档
github.com/cypress-io/… 官方代码模板仓库
www.npmjs.com/package/@cy… vue组件测试配套工具库
vite-vue 本文示例代码仓库
关注我们
大家的支持是我们继续前进的动力,快来关注我们深信服前端团队吧~
同时,如果对我们感兴趣的话,欢迎加入我们,目前有大量岗位需求,欢迎投递简历到 [email protected]
。