本文首发我们公司Blog
本文首发于我们公司博客
聊聊前端开发的测试 - Coding 博客最近在做 Coding 企业版 前端开发时花了很多时间写测试,于是和大家分享一些前端开发中的测试概念与方法。 什么是写测试代码 我理解的写测试其实是你写一些代码来验证你所谓的可以交付的代码是你所预期的blog.coding.net
在自己这里也转一发
最近在做 Coding 企业版前端开发时花了很多时间写测试,于是和大家分享一些前端开发中的测试概念与方法。
什么是写测试代码
我理解的写测试其实是你写一些代码来验证你所谓的可以交付的代码是你所预期的设计,有一些朋友叫他 TDD 也就是测试驱动型的设计,其实到底是先写代码还是先写测试,并不是最重要的,倒是能给你信心这个代码是符合设计的更重要。
为什么要测试,前端需要测试么
这个问题不是这篇分享要和大家聊的,但是作为曾经也有这样疑问的我还是简单提一下。我们经常过于自信自己的代码,因为编写的时候已经做过 debug 调试,完事后觉得足够了,或者期待下次重构再调整之。结果遇到 bug 无法最快时间确定问题,别人接手代码也不知道这个模块的设计意图和使用方法,必须跳进去读代码,也不清楚改了一些内容后会不会影响这个模块功能,又得耗时再次 debug 。在弱类型的语言尤其前端开发中尤为明显。那种决定暂时弃之而不顾的的思想很可怕,因为我们没有听过过勒布朗法则:稍后等于永不。
聊聊测试的几种类型
单元测试
从字面意思理解,写一段代码来测试一个单元。何为单元?其实和编程语言相关,他有可能是一个 function,一个 module 一个package 一个类,当然再 js 中也很有可能只是一个 object 。既然如此,那么测试这样的一个小块基本上就是比较孤立,单独验证这个小块的逻辑,一个 function 的输入输出,一个算法的功能和复杂度等等。接下来举几个企业版前端开发中的实际案例。
我们使用jest作为测试框架(断言库)。jest会自动搜索所有文件目录下的.spec.js结尾的文件,然后执行测试。断言库其实还有很多,他们都具备类似 describe , it , expect 些 api。对于一个没有其他依赖的纯函数,例如 redux 中同步 action 或 reducer。 我们要测的当然就是输入用例然后对应输出是否符合预期
it('should return showMore action', () => {
expect(showMore()).toEqual({
type: ACTION.DEMO_LIST_REMOVE_ITEM,
});
});
我们注意到这样的一个 function 并没有 I/O 和 UI 上的依赖,他更有利于做单元测试。其中的 it 接受一个 string 参数,描述一个小测试。另一个就是测试方法体函数,it 这种测试不能单独使用,一般都包在一个 describe 方法下成为的方法组。那方法体里写什么呢,其实我也可以写成
if (showMore().type !== ACTION.DEMO_LIST_REMOVE_ITEM)
throw 'failed'
只要抛出异常那么框架就会认为这条测试跑不过。当然 expect 则 api 更加的漂亮,拥有 toEqual toBe、toMapSnapshot等判断 api 确定两个条件之间的关系.
对于纯函数的测试并不难,难的还是如何把代码写的更可单元测试化,而不要有太多的依赖。
集成测试
事实上很多情况小块代码还是会有函数和 I/O 依赖,比如一些 code 依赖 Ajax 或者 localStorage 或者 IndexedDB ,这样的代码是不能被 united-test 的,于是我们需要mock相应依赖的接口拿到上下文测试我们的代码,这样的测试叫集成测试。我们项目中主要依赖了 js-dom 和异步的 action 。下面分别讨论
涉及依赖的函数情况--(异步action)
事实上很多情况函数还是会有函数和I/O依赖,最典型的就是异步action等,他的I/O可能会依赖store.getState(),自身又会依赖异步中间键。这类使用原生js测试起来是比较困难的。我们思考我们测试目的,即当我们触发了一个action后它经历了一个圈异步最终store.getAction中这个action拿到的数据是否和我们预期一致。既然大家依赖redux中store的生命周期与store,于是我们需要两个工具库 redux-mock-store和nock ,于是测试就变成了这样。
...
import configureMockStore from 'redux-mock-store';
import nock from 'nock';
import thunk from 'redux-thunk';
const mockStore = configureMockStore([thunk]);
// 配置mock的store,也让他有我们相同的middleware
describe('get billings actions', () => {
afterEach(() => nock.cleanAll());// 每执行完一个测试后清空nock
it('create get all Billings action', () => {
const store = mockStore({
// 以我们约定的初始state创建store,控制I/O依赖
APP: { enterprise: { key : 'codingcorp' } }
});
const data = [
// 接口返回信息
{ ...
},
];
nock(API_HOST)// 拦截请求返回假定的response
.get(`/api/enterprise/codingcorp/billings`)
.reply(200, { code: 0, data })
return store.dispatch(actions.getAllBillings())
.then(() => {
expect(store.getActions()).toMatchSnapshot();
});
});
});
- 用nock来mock拦截http请求结果,并返回我们给定的response。
- 用redux-mock-store来mock store的生命周期,需要预先把middleware配成和项目一致。
- desribe会包含一些生命周期的api,比如全部测试开始做啥,单个测试结束做啥这类api。这里每执行完一个测试就清空nock。
- 用了jest中的toMatchSnapshot api判断两个条件一致与否。原先可能要写成
expect(store.getActions()).toEqual({data ...});
这样,你需要把equal里的东西都想具体描写清楚,而toMatchSnapshot可在当前目录下生成一个snapshot存放这个当前结果,写测试时看一眼结果是预期的就ok可提入commit。如果改坏了函数就不匹配snapshot了。
涉及依赖的函数情况--(react component)
我们写的很多component是extends component 的jsx,测试这类需要一个 mock component 的工具库 Enzyme 。
it('should add key with never expire', () => {
...
挂载我们的dom
const wrapper = shallow(
<TwoFactorModal
verifyKey={verifyKeySpy}
onVerifySuccess={onVerifySuccessSpy}
/>
);
// wrapper的setstate方法
wrapper.setState({
name: 'test',
password: '123',
});
const name = 'new name';
const content = 'new content';
const expiration = '2016-01-01';
wrapper.find('.name').simulate('change', {}, name);
wrapper.find('.content').simulate('change', {}, content);
expect(wrapper.find('.permanentCheck').prop('checked')).toBe(true);
// 此处也可以使用toMatchSnapshot
// submit to add
wrapper.find('.submitBtn').simulate('click', e);
return promise.then(() => {
expect(onCheckSuccess).toBeCalledWith({
name,
password,
});
});
});
Enzyme 给我们提供了很多 react-dom 的事件操作与数据获取。
这类component的测试一般分为
-
Structural Testing 结构测试
主要关心一个界面是否有这些元素
例如我们有一个界面是
结构化测试将包含:
- 一个title包含“登入到codingcorp.coding.net”
- 一个副标题包含“..”
- 两个输入框
- 一个提交按钮
...
比较方便的实现就是利用 jest的snapshot 测试方法,先做一个预期生成snapshot,之后的版本与预期对比。
Interaction Testing 交互测试
比如上述案例触发提交按钮,他应该返回给我用户名和密码,并得到验证结果
这类一般使用 Enzyme 比较方便
样式测试
UI的样式测试为了测试我们的样式是否复合设计稿预期。同时通过样式测试我们可以感受当我们 code 变化带来的ui变化,以及他是否符合预期。
inline style
如果样式是inline style,这类测试其实直接使用 jest 的snapshot testing 最方便,一般在组件库中使用。
css
这部分其实属于 E2E 测试中的部分,这里提前讲,主要解决的问题是我们写出来的ui是否符合设计稿的预期。我们使用 BackstopJS 他的原理是通过对页面的viewports和 scenarios 等做配置,利用 web-driver 获取图片,与设计稿或者预期图做 diff,产生报告。
// 需要测试的模块元素定义
"viewports": [
{
"name": "password", //密码框
"width": 320,
"height": 480
},
],
"scenarios": [
{
"label": "members",
"url": "/member/admin",
"selectors": [ // css选择器
".member-selector"
],
"readyEvent": "gmapResponded",
"delay": 100,
"misMatchThreshold" : 1,
"onBeforeScript": "onBefore.js",
"onReadyScript": "onReady.js"
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"casperFlags": [],
"engine": "slimerjs",
"report": ["browser"],
"debug": false
}
最后会得出类似这样的报告
E2E 测试
E2E 测试是在实际生产环境测试整个app,通常来说这部分工作会让测试人工做,并在实体环境跑,就像用户实际在操作一样。靠人工做遇到项目逻辑比较复杂,则需要每一个版本都要测很多逻辑,担心提交一个影响了其他部分。其实也有比较好的自动化跑脚本方案能帮助测试,我们使用 selenium-webdriver 工具配合async await进行自动化E2E测试。
const {prepareDriver, cleanupDriver} = require('../utils/browser-automation')
//...
describe('member', function () {
let driver
...
before(async () => {
driver = await prepareDriver()
})
after(() => cleanupDriver(driver))
it('should work', async function () {
const submitBtn = await driver.findElement(By.css('.submitBtn'))
await driver.get('http://localhost:4000')
await retry(async () => {
const displayElement = await driver.findElement(By.css('.display'))
const displayText = await displayElement.getText()
expect(displayText).to.equal('0')
})
await submitBtn.click()
})
selenium-webdriver 提供了很多浏览器的操作以及对元素对查找方法,以及元素内容的获取方法,比如这里的 By.css 选择器。
有时候用户端的设备很不一致,需要在不同设备上的匹配,于是我们可以用 selenium-webdriver 搭配 sourcelab 的设备墙进行
后来,由于写测试click还是比较麻烦,尤其对测试而言,我们选用了
testcafe,它能录制在浏览器中每个事件的宏,然后生成测试。这样在对他调整一下,e2e的测试时间就大大降低了。
测试覆盖率与代码变异测试
测试覆盖率表达本次测试有有多少比例的语句,函数分支没有被测到。当然绝对数字作为代码质量依据并没有什么意义,因为它是根据我们写的测试来的。倒是学习为什么有些代码没有被覆盖到,以及为什么有些代码变了测试却没有失败。很有意义。我们在jestconfig中配置完目标数据后,每次他会检测我们的测试覆盖率并给我们报告
Function Coverage 函数覆盖
顾名思义,就是指这个函数是否被测试代码调用了。以下面的代码为例
,对函数exchange要做到覆盖,只要一个测试——如expect(exchange(2, 2)) 就可以了。如果连函数覆盖都达不到,那这个函数是否真的需要。
let z = 0
if (x>0 && y>0) {
z=x
}
return z
}```