网站首页 > 技术文章 正文
01
为什么需要单元测试
对于一些长期多人维护的大项目,在没有单元测试的情况下,隔一段时间很可能由于疏忽重新踩坑
有了单元测试我们就可以为这些问题点编写对应的测试代码,每次提交代码前都执行一遍,可以极大的降低相同 bug 重复出现的概率。
将复杂的代码拆解成为更简单、更容易测试的片段,某种程度上编写单元测试的过程会潜移默化的提高我们代码的质量(TDD)。
02
如何写单元测试
单元测试一般包含以下几个部分:
- 被测试的对象是什么(组件、mixins、utils…)
- 要测试该对象的什么功能(props、method、emit、页面渲染…)
- 实际得到的结果
- 期望的结果
- mock
具体到某个单元测试,往往包含以下几个步骤:
- 准备阶段:构造参数,创建 mock 等
- 执行阶段:用构造好的参数执行被测试代码
- 断言阶段:用实际得到的结果与期望的结果比较,以判断该测试是否正常
- 清理阶段:清理准备阶段对外部环境的影响,移除在准备阶段创建的实例等
针对大而复杂的项目时,单元测试应该围绕那些可能会出错的地方及边界情况。
03
前端单元测试工具
在前端领域,有多种单元测试工具可供选择。一些常见的单元测试工具包括:
- 断言(Assertions):用于判断结果是否符合预期。有些框架需要单独的断言库。
- 异步测试:有些框架对异步测试支持良好。
- Mock:用于特殊处理某些数据,比如隔离非必要第三方库/组件
- 代码覆盖率:计算语句/分支/函数/行覆盖率
考虑到上手难度以及功能全面性,考虑使用的测试工具为:JEST
04
JEST快速开始
1、安装:npm install --save-dev jest, 如果测试的为VUE框架,需要再借助vue test utils工具(https://v1.test-utils.vuejs.org/zh/)
如果使用vue-cli,选择 "Manually select features" 和 "Unit Testing",以及 "Jest" 作为 test runner即可
2、一旦安装完成,cd 进入项目目录中并运行 npm run test:unit即可
3、编写测试
这里举个简单例子(PayState.vue)
<template>
<div class="result-type-wrapper">
<Icon class="result-icon succ" v-if="state == 'success'"></Icon>
<p class="result-title">{{ title }}</p>
<p class="opera-tips">{{ tips }}</p>
<slot></slot>
</div>
</template>
<script>
export default {
props: ['title', 'tips', 'state']
};
</script>
在 tests/unit 中创建一个 payState.test.js。在其内容中,引入 PayState.vue,以及 shallowMount 方法,并添加测试的概要:
import payState from '../PayState.vue';
import { shallowMount } from '@vue/test-utils';
describe('payState.vue', () => {
it('v-if 验证', () => {
let wrapper = shallowMount(payState, { 挂载选项 })
expect(wrapper.findComponent('.result-icon.succ').exists()).toBeFalsy();
})
})
- describe 一般概述了测试会包含什么,可以理解成文件夹的概念
- it (别名test)表示测试应该完成的主题中一段单独的职责。随着我们为组件添加更多特性,在测试中就会添加更多 it 块
- expect表示作出断言,我们可以看到期望的和实际的结果,也能看到期望是在哪一行失败的。
- 关于断言中匹配器的使用,可以参考文章:JEST匹配器(https://jestjs.io/zh-Hans/docs/using-matchers)
- 而挂载选项的话,参考Vue test utils的官网文档:挂载选项(https://v1.test-utils.vuejs.org/zh/api/options.html#context)
05
Vue可测试的内容
这里列举一些在VUE中能测试到的内容,具体是否需要测试,可以按实际情况分析,如果只是获取数据,没有任何业务逻辑,可以忽略
Props
1)通过在加载一个组件时传递 propsData,就可以设置 props 以用于测试
const wrapper = shallowMount(CreditCard, {
propsData: {
showFooter: false
}
})
2)可测试的内容:值的边界情况,以及特殊字符的表现,非要求必传的时候值的表现情况
Computed 计算属性
1)可以使用 shallowMount 并断言渲染后的代码来测试计算属性
例如,假设你有一个计算属性 fullName,它由 firstName 和 lastName 计算而来,你可以编写测试来确保 fullName 在 firstName 和 lastName 改变时返回正确的值。
it('计算属性 fullName 正确计算', () => {
const wrapper = shallowMount(MyComponent, {
propsData: {
firstName: 'John',
lastName: 'Doe',
},
});
expect(wrapper.vm.fullName).toBe('John Doe');
wrapper.setData({ firstName: 'Jane' });
expect(wrapper.vm.fullName).toBe('Jane Doe');
});
2)可测试的内容:值的边界情况,以及特殊字符的表现
测试组件渲染输出
1) v-if/v-show 是否符合预期,常用到的断言对象分别是dom.exists() 及 dom.isVisible()
2) 类名和DOM属性测试,常用到的断言对象分别是dom.classes() 及 dom.attributes()
测试组件方法
1)模拟用户行为:通过findComponent或者findAllComponents来获取DOM或者自组件,并通过trigger触发注册的事件,从而断言结果是否符合预期
2)对于一些mixins引入的外部函数,如想判断是否被调用,可以通过mock的方式以及toBeCalled的匹配器来判断
it('emit功能验证', () => {
wrapper.findComponent('.point-card-close').trigger('click');
expect(outsideMock).toBeCalled();
expect(outsideMock).toHaveBeenCalledTimes(1);
})
3)emit的事件可以通过emitted方法来获取
expect(wrapper.emitted().foo).toBeTruthy()
expect(wrapper.emitted().foo[0]).toEqual([123])
测试 vue-router
1)通过在shallowMount渲染组件的时候传入mock数据,来模拟$route、$router对象
const wrapper = shallowMount(Payment, {
mocks: {
$route: {
query: {}
},
$router: {
replace: jest.fn()
}
}
})
测试mixin
在组件中或全局注册mixin、挂载组件、最后检查mixin是否产生了预期的行为
import MyComponent from '@/components/MyComponent';
import MyMixin from '@/mixins/MyMixin';
import { shallowMount } from '@vue/test-utils';
it('测试 mixins 修改状态和数据', () => {
const wrapper = shallowMount(MyComponent, {
mixins: [MyMixin],
});
// 确保 mixin 修改了组件的数据
expect(wrapper.vm.mixinData).toBe('Mixin Data');
// 确保 mixin 修改了组件的状态
expect(wrapper.vm.$store.state.mixinState).toBe(true);
})
})
测试VUEX
主要有两种方式:
- 单独测试store中的每一个部分:我们可以把store中的mutations、actions和getters单独划分,分别进行测试。(小而聚焦,但是需要模拟Vuex的某些功能)
- 组合测试store:我们不拆分store,而是把它当做一个整体,我们测试store实例,进而希望它能按期望输出(避免互相影响实例,使用vue test utils提供的localVue)
快照测试
简单的解释就是获取代码的快照,并将其与以前保存的快照进行比较,如果新的快照与前一个快照不匹配,测试会失败。
当一个快照测试用例失败时,它提示我们组件相较于上一次做了修改。如果是计划外的,测试会捕获异常并将它输出提示我们。如果是计划内的,那么我们就需要更新快照。
食用方法 :expect(wrapper.element).toMatchSnapshot()
06
测试报告与覆盖率
覆盖率可以简单理解为已被测试代码,它可以从一定程度上衡量我们对代码测试的充分性。原则上我们追求的单元测试覆盖率目标是100%,但业务场景多的情况几乎是不可能。
因此我们可以只针对核心底层的模块书写单元测试,核心复杂功能尽量覆盖率做到最高,业务类的酌情处理。
四个概念:
语句覆盖率:是不是每个语句都执行了
分支覆盖率:是不是每个if代码块都执行了
函数覆盖率:是不是每个函数都调用了
行覆盖率:是不是每一行都执行了
也可以打开对应的报告查阅未覆盖到的模块内容,并进行对应的修改
- 「7x」表示在测试中这条语句执行了 7 次
- 「I」是测试用例 if 条件未进入,即没有 if 为真的测试用例
- 「E」是测试用例没有测试 if 条件为 false 时的情况
- 即测试用例中 if 条件一直都是 true,得写一个 if 条件为 false 的测试用例,即不走进 if 条件里面的代码,这个 E 才会消失
关于覆盖率的阈值,已经比对的文件,具体可以参考jest.config.js文件,这里设置为80
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
07
结合LangChain生成基础单测代码
不同于功能或算法库,编写Vue的单元测试用例时,常常会发现存在许多通用且重复的部分。因此,可以考虑借助AI的能力来辅助生成基本的Jest单元测试代码。
尽管生成的单元测试代码可以作为起点,帮助编写基本的测试用例,但由于代码中通常包含一些业务特定的逻辑,可能需要进行二次处理。因此,生成的测试代码仅供参考,需要根据具体情况进行调整和补充。
源码参考:https://code.37ops.com/zhouguilin/openai-code-generator/-/blob/ai-unit-test/src/unit-creator.js
这是生成的某个测试文件效果实例:
通过AI的协助,我们已经能够生成基本的测试用例代码,包括render、methods、computed、watch以及slot。这显著降低了我们编写重复代码的时间成本,然后把重点放在特殊业务逻辑的测试用例编写上。
import { shallowMount } from '@vue/test-utils';
import CreditCard from '../CreditCard.vue';
jest.mock('@utiles/officialStore', () => ({
RES_HOST: 'mocked-res-host'
}));
jest.mock('@store/officialWebStore', () => ({
GET_CARD_SCHEMES_ACTION: 'mocked-get-card-schemes-action',
SET_CREDIT_CARD: 'mocked-set-credit-card'
}));
jest.mock('vuex', () => ({
mapState: jest.fn()
}));
describe('CreditCard', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(CreditCard, {
propsData: {
curPayType: {
currency: 'USD'
},
curLocationVal: 'mocked-location',
discountTransMount: 100,
curCoins: {
TRANS_AMOUNT: 50
},
isCoins: true
},
mocks: {
$store: {
state: {
gameId: 'mocked-game-id'
},
commit: jest.fn(),
dispatch: jest.fn()
},
window: {
webstorev2DataLayer: {
push: jest.fn()
}
}
},
slots: {
default: '<div class="default-slot">Default Slot Content</div>',
namedSlot: '<div class="named-slot">Named Slot Content</div>'
},
scopedSlots: {
contextualSlot: '<div class="contextual-slot" v-bind="props">Contextual Slot Content</div>'
}
});
});
it('renders the component', () => {
expect(wrapper.exists()).toBe(true);
});
it('renders the credit card container when curPayType is provided', () => {
expect(wrapper.find('.credit-card-container').exists()).toBe(true);
});
it('does not render the credit card container when curPayType is not provided', () => {
const wrapperWithoutCurPayType = shallowMount(CreditCard);
expect(wrapperWithoutCurPayType.find('.credit-card-container').exists()).toBe(false);
});
it('renders the card item when creditCardList is provided', () => {
expect(wrapper.findAll('.card-item').length).toBe(2);
});
it('does not render the card item when creditCardList is not provided', () => {
const wrapperWithoutCreditCardList = shallowMount(CreditCard);
expect(wrapperWithoutCreditCardList.findAll('.card-item').length).toBe(0);
});
it('selects the credit card when clicked', () => {
const cardItem = wrapper.find('.card-item');
cardItem.trigger('click');
expect(wrapper.vm.curCreditCard).toBe('card1');
});
it('calls the getCardSchemes method when created', () => {
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
currency: 'USD',
game_id: 'mocked-game-id',
location: 'mocked-location',
amount: 50
});
});
it('calls the getCardSchemes method when discountTransMount is updated', () => {
wrapper.setProps({ discountTransMount: 200 });
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
currency: 'USD',
game_id: 'mocked-game-id',
location: 'mocked-location',
amount: 200
});
});
it('calls the getCardSchemes method when curCoins is updated and isCoins is true', () => {
wrapper.setProps({ curCoins: { TRANS_AMOUNT: 100 } });
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('mocked-get-card-schemes-action', {
currency: 'USD',
game_id: 'mocked-game-id',
location: 'mocked-location',
amount: 100
});
});
it('computes the creditCardList correctly', () => {
wrapper.setData({
creditCardList: ['card1', 'card2']
});
expect(wrapper.vm.creditCardList).toEqual(['card1', 'card2']);
});
it('computes the curCreditCard correctly', () => {
wrapper.setData({
curCreditCard: 'card1'
});
expect(wrapper.vm.curCreditCard).toBe('card1');
});
it('computes the letter correctly', () => {
wrapper.setData({
letter: {
pleaseCard: 'Please select a card'
}
});
expect(wrapper.vm.letter).toEqual({
pleaseCard: 'Please select a card'
});
});
it('watches the discountTransMount property and calls the getCardSchemes method when it changes', () => {
wrapper.setProps({ discountTransMount: 200 });
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('GET_CARD_SCHEMES_ACTION', {
currency: 'USD',
game_id: 'mocked-game-id',
location: 'mocked-location',
amount: 200
});
});
it('watches the curCoins property and calls the getCardSchemes method when it changes and isCoins is true', () => {
wrapper.setProps({ curCoins: { TRANS_AMOUNT: 100 } });
expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('GET_CARD_SCHEMES_ACTION', {
currency: 'USD',
game_id: 'mocked-game-id',
location: 'mocked-location',
amount: 100
});
});
it('renders the default slot content', () => {
expect(wrapper.find('.default-slot').exists()).toBe(true);
expect(wrapper.find('.default-slot').text()).toBe('Default Slot Content');
});
it('renders the named slot content', () => {
expect(wrapper.find('.named-slot').exists()).toBe(true);
expect(wrapper.find('.named-slot').text()).toBe('Named Slot Content');
});
it('renders the contextual slot content with the correct props', () => {
expect(wrapper.find('.contextual-slot').exists()).toBe(true);
expect(wrapper.find('.contextual-slot').text()).toBe('Contextual Slot Content');
expect(wrapper.find('.contextual-slot').attributes('cur-pay-type')).toBe('{"currency":"USD"}');
expect(wrapper.find('.contextual-slot').attributes('cur-location-val')).toBe('mocked-location');
expect(wrapper.find('.contextual-slot').attributes('discount-trans-mount')).toBe('100');
expect(wrapper.find('.contextual-slot').attributes('cur-coins')).toBe('{"TRANS_AMOUNT":50}');
expect(wrapper.find('.contextual-slot').attributes('is-coins')).toBe('true');
});
});
作者:加鸿
来源-微信公众号:三七互娱技术团队
出处:https://mp.weixin.qq.com/s/yFM_LzvmYV9Xp-GaCLm1Ig
- 上一篇: 前端单元测试以及自动化构建入门
- 下一篇: 使用Jest进行前端单元测试
猜你喜欢
- 2025-05-22 一天涨 23k Star 的开源项目「GitHub 热点速览」
- 2025-05-22 如何选择VueJS的两个API Composition API或者Options API
- 2025-05-22 「评测」 声色——海贝 Crystal6 多单元动铁耳机
- 2025-05-22 常用的七种性能测试
- 2025-05-22 接口测试及其测试流程
- 2025-05-22 Java开发中的自动化测试框架:从零开始玩转测试工具
- 2025-05-22 别克君越1.5t机电单元维修
- 2025-05-22 前端代码Review,一次性掰扯明白!
- 2025-05-22 C++语言的单元测试与代码覆盖率
- 2024-09-22 vue入门:使用mockjs搭建vue项目测试服务器
你 发表评论:
欢迎- 05-23浅谈3种css技巧——两端对齐
- 05-23JSONP安全攻防技术
- 05-23html5学得好不好,看掌握多少标签
- 05-23Chrome 调试时行号错乱
- 05-23本文帮你在Unix上玩转C语言
- 05-23Go 中的安全编码 - 输入验证
- 05-2331个必备的python字符串方法,建议收藏
- 05-23Dynamics.js – 创建逼真的物理动画的 JS 库
- 最近发表
- 标签列表
-
- 前端设计模式 (75)
- 前端性能优化 (51)
- 前端模板 (66)
- 前端跨域 (52)
- 前端缓存 (63)
- 前端react (48)
- 前端md5加密 (49)
- 前端路由 (55)
- 前端数组 (65)
- 前端定时器 (47)
- 前端接口 (46)
- Oracle RAC (73)
- oracle恢复 (76)
- oracle 删除表 (48)
- oracle 用户名 (74)
- oracle 工具 (55)
- oracle 内存 (50)
- oracle 导出表 (57)
- oracle约束 (46)
- oracle 中文 (51)
- oracle链接 (47)
- oracle的函数 (57)
- mac oracle (47)
- 前端调试 (52)
- 前端登录页面 (48)
本文暂时没有评论,来添加一个吧(●'◡'●)