内容简介:在撰写单元测试用例之前,我们需要了解到撰写测试用例的原因。单元测试(Unit Test):前端单元测试,在以前也许是一个比较陌生的工作,但是前端在经历了这几年的发展之后,我们对于代码的鲁棒性要求逐渐提升,承载了更多的业务逻辑的同时,作为整个链路上最接近用户的部分,系统崩溃阻塞的成本非常之高。如果你采用的是SSR,那么直接在服务端渲染报错则是更为致命的。前端的单元测试能够在一定程度上保证:
在撰写单元测试用例之前,我们需要了解到撰写测试用例的原因。 写测试用例的目的在于保证代码的迭代安全,并不是为了100%的coverage或者是case pass,coverage和case仅仅是为了实现代码安全的因素。
单元测试(Unit Test):前端单元测试,在以前也许是一个比较陌生的工作,但是前端在经历了这几年的发展之后,我们对于代码的鲁棒性要求逐渐提升,承载了更多的业务逻辑的同时,作为整个链路上最接近用户的部分,系统崩溃阻塞的成本非常之高。如果你采用的是SSR,那么直接在服务端渲染报错则是更为致命的。
前端的单元测试能够在一定程度上保证:
- 在迭代过程中保证每次提交的代码的质量;
- 在代码的重构过程中,原始功能的完整性;
- 每次代码迭代的副作用可控;
相对于后端代码来说,前端代码更多地会涉及到DOM相关的内容,对于非结构化的内容如何进行测试呢?
airbnb提供了一个比较合适的React单元测试解决方案,结合Jest以及husky,可以保证每次commit的代码都符合规范,并且coverage内的代码功能完整。
UT之于library
库对于单元测试的要求是非常高的。因为一个lib可能被多个业务线以及工程所引入, 一旦这个lib出现了任何问题,影响到的范围是非常大的 。我们又不可能要求QA对于多个业务线进行回归(怕是他们要杀了我们祭天吧)。
为了保证lib的迭代不会影响到原有的业务功能,单元测试是一个非常好的方法。由于我们主要的技术栈还是基于React的各种解决方案,所以有比较多的业务组件以及公共组件,这些组件被多个业务线使用。lerna架构的组件工程在每次commit的时候都会跑UT,来进行功能回归。
UT之于业务
业务代码一般对于单元测试的需求并不如lib那样高,但是在某些核心业务逻辑中接入UT,也是可以保证代码整体的质量的。最起码可以保证业务代码在正常的渲染过程中不发生报错。
框架
前面简单描述了一下单元测试对于前端代码的重要性,很多人说现在的前端圈子和娱乐圈一样,确实,目前可选的测试框架林林总总有很多,经历了jasmine、mocha,现在来到了Jest。
TL;DR
9102年了, Jest 可以说是目前前端最好的测试框架了。可以进行快速配置,和enzyme很好地结合,能够保证在React技术栈中,快速跑起来一个测试用例。
但是,最吸引人的还是其内置的coverage报告,可以快速生成代码覆盖率。
相比于测试框架,React的测试库似乎没有什么其他的选择了, enzyme 基本可以满足任何前端的测试需求。但是对于异步强交互的页面来说,撰写测试用例的学习成本还是比较高的。
技术栈
最终我们为了各种场景下React的单元测试,集成了下面的lib:
- Jest:单元测试框架
- enzyme: React测试库
- Nock: 异步请求模拟
- Async-wait-until: 异步操作结束通知
- Husky: pre-commit阶段执行单元测试
配置
Jest
Jest本身就以配置简单著称,而enzyme更是可以即插即用的测试库。所以配置过程要比较轻松。
module.exports = { // 单元测试环境根目录 rootDir: path.resolve(__dirname), // 指定需要进行单元测试的文件匹配规则 testMatch: [ '<rootDir>/test/**/__test__/*.js' ], // 需要忽略的文件匹配规则 testPathIgnorePatterns: [ '/node/modules' ], testURL: 'http://localhost/', // 是否收集测试覆盖率,以及覆盖率文件路径 collectCoverage: true, coverageDirectory: './coverage' }; 复制代码
上面是几个比较重要的配置项。其中大部分都是比较好理解的,而 testURL
这个配置项需要说明一下, 这个规则表示当前测试用例所运行的URL ,虽然测试的时候我们看不到完整的页面,但是测试用例本身是挂载到一个页面中的,而这个页面的URL就是通过 testURL
指定的。
在这个Jest配置下,所有的测试用例中,如果执行 location.href
都会拿到 http://localhost/
这个URL的,这个配置项在进行需要网络请求的case中是很关键的。
在执行的时候,可以指定Jest的配置文件路径:
~ jest --config ./scripts/jest.config.js 复制代码
如果没有指定文件路径的话,默认则是取当前文件路径的配置文件。
enzyme
enzyme本身是不需要配置的,作为一个即插即用的React测试库,也算是让我们前端脱离了 配置工程师 的苦海。
但是基于React进行开发,则需要安装对应的React Adapter,比如如果你需要使用 static getDerivedStateFromProps
方法,那么就需要引入 enzyme-adapter-react-16
的库来保证enzyme渲染的版本和你使用的版本是一致的。
Jest在进行UT的过程中,会首先检查工程是否有配置 .babelrc
文件,如果配置了,则会自动根据这个文件来进行babel编辑,然后执行测试用例。
一个随手搭建的演示环境的依赖:
"dependencies": { "react": "^16.7.0", "react-dom": "^16.7.0" }, "devDependencies": { "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1", "babel-preset-env": "^1.7.0", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "babel-preset-stage-3": "^6.24.1", "enzyme-adapter-react-16": "^1.7.1", "enzyme": "^3.8.0", "jest": "^23.6.0" }, "scripts": { "test": "jest --config ./jest.config.js" } 复制代码
// ./__test__/index.js import Test from '../src'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; Enzyme.configure({ adapter: new Adapter() }); 复制代码
而enzyme的adapter是需要进行初始化的,通过 Enzyme.configure
指定需要引入的adapter实例。
这样就完成了一个Enzyme + React + Jest的环境。
撰写一个简单的测试用例
断言
目前,各种测试框架的断言已经开始收敛,Jest采用的断言语法和我们之前使用的mocha语法类似。
一个test suite可以用 describe
来描述,一个test suite可以包含多个case,来测试各种场景下的组件渲染结果。
我们先给出一个非常简单的React组件:
import React from 'react'; export default class Text extends React.Component { render() { return (<div className="test-container" />) }; } 复制代码
对于这个组件,我们需要判断是否成功渲染出来了div元素,并且元素的类名是 test-container
。
这是一个极简版本的case:
describe('test suite: Test component', () => { it('case: expect Test render a div with className: test-container', () => { const wrapper = shallow(<Test />); expect(wrapper.find('.test-container').length).toEqual(1); }); }); 复制代码
执行 npm run test
,可以得到下面的结果:
可以看到suites和cases的通过情况,以及各种覆盖率结果。其实前端单元测试也可以这么简单的。
关于enzyme的三个核心渲染方法,mount、render以及shallow,网上有很多文章介绍三者之间的区别,这里就不班门弄斧了。mount应该是我写测试用例最常用的方法吧,毕竟大部分组件的逻辑都需要真实挂载出来,才能够进行用例测试。
测试用例也可以很复杂
最近有一个比较复杂的组件,需要接入单元测试,当时在开发的时候太天真,现在想起来真的是追悔莫及。组件内部包含: fetch请求、时间获取、 history
操作,并且含有非常多的人机交互逻辑 。
这样的组件现在想起来是非常不规范的,但是为了保证以后修改的时候,业务逻辑的鲁棒,也不得不强行为其添加单元测试。
下面有很多case,大部分case都是在实际coding过程中遇到的,希望能够帮助到有同样需求的人。
history和Date.now()
在业务代码中,很多时候我们都需要进行页面的跳转,或者hash的修改。所有对于 location
的操作都会落在 window.location
的对象上。
enzyme实际上为我们构建了一个虚拟的DOM环境,我们可以拿到对应的DOM元素以及 window
、 document
对象来进行DOM操作。
Date
也是类似的,也是一个全局的对象,以前我们通过集成 js-dom
来进行模拟,而现在enzyme和Jest为我们做好了这些工作。
看下面这个组件:
class Time extends React.Component { static propTypes = { time: PropTypes.number }; constructor(props) { super(props); this.state = { before: Date.now() < props.time } } render() { const { before } = this.state; const { time } = this.props; if (before) { return ( <div className="before"> {`now is before time: ${time}`} </div> ); } else { return ( <div className="after"> {`now is after time: ${time}`} </div> ); } } } 复制代码
在撰写单元测试的时候,我们会发现,由于当前时间的不一致,所以作为 props
传入的时间在和 Date.now()
进行比较,得到的结果是不一致的,这样会导致测试用例的结果不可控。
为了保证 Date.now()
得到的值是一致的,我们需要改写DOM上的 Date
对象。
describe('test suite: Time component', () => { const NOW_TO_CACHE = global.Date.now; const NOW_TO_USE = jest.fn(() => 1547717952668); beforeEach(() => { global.Date.now = NOW_TO_USE; }); afterEach(() => { global.Date.now = NOW_TO_CACHE; }); it('case: now is less than props\' time', () => { const wrapper = shallow(<Time time={1547717952669} />); console.log(Date.now()) expect(wrapper.find('.before').length).toEqual(1); }); it('case: now is greater than props\' time', () => { const wrapper = shallow(<Time time={1547717952667} />); console.log(Date.now()) expect(wrapper.find('.after').length).toEqual(1); }) }); 复制代码
beforeEach
和 afterEach
两个hook在每一个case执行之前或者之后,会分别执行,在每个case之前,进行 global.Date.now
的改写,然后在case结束之后,将 global.Date.now
恢复为原本的方法。
jest.fn
会生成一个Mock函数,这个函数和其他函数不一样的地方在于,这个函数会记录到其被执行的一些信息,比如:
this
可以看到,对于所有的 Date.now()
方法,得到的当前时间都被复写成了一个确定的数字,这样就可以保证你的测试用例的时间无关性。
对于 history
、 Date.now
这类挂载到 window
或者 document
上面的实例对象,我们都可以通过 jest.fn
来复写其方法,保证这些方法被调用的顺序以及调用结果的正确性,我们也可以在 jest.fn
内部进行断言,从而判断每次执行的过程中是否发生错误。
fetch请求
前端作为View,部分场景下比较依赖后端提供的Model来进行渲染,API的正确性很多时候会直接影响到整个页面的渲染结果是否正确。
并且部分场景中,某些代码也许是在 Promise
被 resolve
了之后才会被调用。
所以我们需要模拟fetch请求,来保证在请求回调中的代码被单元测试覆盖到。
这里就需要用到:
Nock:HTTP server mocking and expectations library for Node.js Async-wait-until:Wait while predicate completes and resolve a Promise
这两个库了。
首先,看下面这个组件:
import React from 'react'; import fetch from 'isomorphic-fetch'; export default class AsyncComponent extends React.Component { constructor(props) { super(props); this.state = { user: {} } } componentDidMount() { this.fetchUser() .then(res => { this.setState({user: res}); }); } fetchUser = () => { return fetch(`${location.origin}/api/user/get`, { method: 'GET' }).then(ret => { return ret.json(); }).catch(err => { console.error(err); }); } render() { const { user } = this.state; return ( <div className="user-profile"> <p className="name">{user.name}</p> <p className="age">{user.age}</p> </div> ); } } 复制代码
组件内部在 componentDidMount
阶段进行了一次fetch请求,来在客户端渲染的时候获取数据,填充到页面中。
同步的测试工作非常简单,根据前面的几个例子,相信你可以对于渲染进行很好地测试了。
Q & A:
Q:其一:如何测试网络请求的回调呢?
我们不可能直接将UT的请求直接打到后台的接口里,这样在没有网络的环境下,UT是通过不了的。所以必须要在本地模拟到近似于真实的网络请求。
A:Nock
Q: 其二:网络请求时异步的,如果撰写异步的测试用例呢?
组件View的更新是在异步的请求resolve之后进行的,而测试用例的执行是同步的,这样就会出现时序问题,所以我们需要将断言和组件的fetch同步执行。
A: async-wait-until
这就是我们引入这两个库的原因了。具体如何结合这两个库来进行异步渲染的单元测试,看下面这个test suite。
import Async from '../src/async'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; import nock from 'nock'; import waitUntil from 'async-wait-until'; Enzyme.configure({ adapter: new Adapter() }); describe('test suite: Async component', () => { beforeAll(() => { nock('http://localhost/api/user') .get('/get') .reply(200, { "name": "lucas", "age": 20 }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect component did mount will trigger re-render', async () => { const wrapper = mount(<Async />); await waitUntil(() => wrapper.state('user').name === 'lucas'); expect(wrapper.find('.name').text()).toBe('lucas'); expect(wrapper.find('.age').text()).toBe('20'); }); }); 复制代码
上面的这个测试用例的核心在于模拟fetch请求,并且等在请求结束再执行对应的断言。
首先,我们为这个test suite增加了两个hook, beforeAll
会在这个suite的所有case执行之前执行一次,而 afterAll
则会在所有的case全部执行完之后,执行一次。
beforeAll
中,我们通过nock模拟了组件中fetch请求的请求结果,给到了一个resolve的响应。
当React执行到 componentDidMount
的时候,会进行fetch请求,这个请求会被打到nock中。这里注意到,我们fetch的URL是 http://localhost/api/user/get
,这就是之前提到的,Jest配置项中设置 testURL
的作用。 testURL
指定的URL会作为测试页面的 location.origin
。
由于fetch是一个异步的过程,我们需要等待fetch被resolve之后,才能够进行断言。
所以,这里用到了 waitUntil
,这个函数接受一个函数作为参数,这个函数会返回一个bool值,当bool值为 true
的时候,表示异步调用结束,可以开始执行后面的逻辑了,当然,我们也可以封装一个自己的 waitUntil
,其本质就是封装一个Promise。
结束了这一个suite之后,代码逻辑会走到 afterAll
的hook中。这里面调用了 nock.cleanAll()
,用于对之前mock的接口进行清理,也就是规范这个mock的作用域仅仅位于当前的suite中。
这时,我们再跑一次 npm run test
,可以得到下面的测试结果:
结合上面的test suite,在单元测试中成功进行了fetch,并且渲染出了正确的结果。
但是细心的小伙伴可能会发现,coverage报告中有一行代码没有被这个test suite覆盖到,这行代码可以定位到fetch的reject中,因为我们仅仅测试了fetch resolve的情况。
为了测试reject的情况,我们需要一个新的suite,在这个suite中,我们mock一个reject响应的接口:
describe('test suite: Async component', () => { let resolve = false; beforeAll(() => { nock('http://localhost/api/user') .get('/get') .reply(400, () => { resolve = true; }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect component fetch error will not block rendering', async () => { const wrapper = mount(<Async />); await waitUntil(() => resolve); expect(wrapper.find('.name').text()).toBe(''); expect(wrapper.find('.age').text()).toBe(''); }); }); 复制代码
由于请求是异步的,并且与resolve的情况不同,我们不知道何时请求会被reject,所以我们需要给nock传入一个回调,来标识fetch结束,请求被reject。
这样就可以测试到reject情况下页面是否成功渲染了,保证了各种condition下,页面或者组件的稳定。
交互模拟
作为链路中toC的部分,前端代码中有许多地方是需要进行人机交互的。在交互过程中,javascript主要以注册事件的方式进行交互响应。
人机交互不仅仅是异步的,并且还包含事件的触发以及回调。这部分测试,enzyme提供了很多有意思的API,来帮助我们完成人机交互过程的单元测试。
考虑下面的这个组件:
import React from 'react'; import fetch from 'isomorphic-fetch'; export default class Text extends React.Component { constructor(props) { super(props); this.state = { value: '' }; } onInputChanged = (e) => { this.setState({ value: e.target.value }); } onClicked = () => { const { value } = this.state; this.postValue(value) .then(res => { this.setState({ value: '' }); }); } postValue = (value) => { return fetch(`${location.origin}/api/value`, { method: 'POST', body: JSON.stringify({value}), }).then(ret => { return ret.json(); }); } render() { const { value } = this.state; return ( <div className="form"> <input value={value} onChange={this.onInputChanged} /> <button className="submit" onClick={this.onClicked}>提交</button> </div> ) } } 复制代码
这是一个常见的React输入框,我们将输入框的 value
绑定到 state
上面。期望能够通过用户输入来改变组件状态,在用户点击提交的时候,可以从页面中取到这个值,并且POST到服务端,在得到了正确的回调之后,清空掉输入框中的内容。
这种需求比较普遍,现在需要为这样一个需求添加一组单元测试,保证这个组件能够稳定运行。
考虑到几个重点:
- 触发输入框onchange事件
- 等待输入框输入事件结束
- 触发按钮点击事件
- 进行fetch
- 等待fetch结束
- 回调中清理input内容
enzyme提供了一些触发事件的方法。当我们使用 mount
将一个组件挂载到虚拟DOM上的时候,可以通过 wrapper.simulate()
方法来触发各种DOM事件。
首先,先测试组件是否正确完成渲染:
it('case: expect input & click operation correct', async () => { const wrapper = mount(<Interaction />); const input = wrapper.find('input').at(0); const button = wrapper.find('button').at(0); expect(input.exists()); expect(button.exists()); }); 复制代码
然后需要触发input的onchange事件,来改变当前的state:
input.simulate('change', { target: { value: 'lucas' } }); expect(wrapper.state('value')).toBe('lucas'); 复制代码
接着,触发按钮的点击事件,进行fetch请求,然后在响应返回之后,清理掉 state
中的内容。
button.simulate('click'); 复制代码
这样就完成了整个组件的操作流程的UT了,执行这个单元测试,可以发现我们的测试已经完全覆盖了所有代码的所有分支了。
下面是完成的test suite:
import Interaction from '../src/interaction'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; import nock from 'nock'; import waitUntil from 'async-wait-until'; Enzyme.configure({ adapter: new Adapter() }); describe('test suite: Async component', () => { let resolve = false; beforeAll(() => { nock('http://localhost/api') .post('/value') .reply(200, () => { resolve = true; return {}; }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect input & click operation correct', async () => { const wrapper = mount(<Interaction />); const input = wrapper.find('input').at(0); const button = wrapper.find('button').at(0); expect(input.exists()); expect(button.exists()); input.simulate('change', { target: { value: 'lucas' } }); expect(wrapper.state('value')).toBe('lucas'); button.simulate('click'); await waitUntil(() => resolve); expect(wrapper.state('value')).toBe('') }); }); 复制代码
整个测试用例完全pass,并且coverage为100%
最后
洋洋洒洒又是一个大长篇,有很多博主会将enzyme、nock、jest这类库分开来讲,但是在实际使用过程中,这几个库却是密不可分的。
单元测试是前端工程化的一个不可避免的阶段性工作,无论是开源工作还是业务工作,保证在每次迭代过程中代码的安全性于人于己都有很大的好处。
最后还是要说,撰写测试用例的时候,一定要切记,单元测试并不是堆砌覆盖率,而是保证每一个功能细节都被覆盖到,不要舍本逐末了。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 使用 Gomock 进行单元测试
- 使用JUnit进行单元测试
- 使用Jest进行React单元测试
- 如何实现插入排序以及进行单元格测试
- 如何对 Jenkins 共享库进行单元测试
- 对 Golang 代码调用 Elasticsearch 进行单元测试
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
YC创业营: 硅谷顶级创业孵化器如何改变世界
兰德尔·斯特罗斯 (Randall Stross) / 苏健 / 浙江人民出版社 / 2014-8-1 / CNY 52.90
在互联网创业成本日益降低、融资却越来越难的今天,硅谷的Y Combinator因何成为全世界创业者趋之若鹜的创业圣地?为什么25岁左右的青年最适合创业?创业者如何才能在遴选面试中脱颖而出?为什么YC特别看好那些主要由黑客组成的创业团队? YC真的歧视女性吗?如何想出能够赢得投资的新点子?创业者应该如何寻找联合创始人? 获准进入Y Combinator及其创业公司全程跟踪批量投资项目的第一人,......一起来看看 《YC创业营: 硅谷顶级创业孵化器如何改变世界》 这本书的介绍吧!