1.前言
单元测试又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。 --维基百科
简单的说,单元测试是一种验证,验证代码功能,方法的实现的正确性。
为什么我们会区分前端和后端的单元测试?对于后端来说,单元测试并不陌生,验证一段逻辑的输入输出是否符合预期就可以,模式也很统一,毕竟编译型语言的本质就是计算。对前端而言,我们可能面对更多的是标记性语言和脚本语言,单元测试的边界很难定义,有渲染也有业务,如何测试也是很多项目争议的地方。
2.先说是不是,再问测什么
前端是不是要写单元测试?
首先,单元测试的重点在于单元,如何把代码拆分成一个个的单元,把业务和逻辑代码分开才是我们最开始需要考虑的问题。单纯对于一个结果的输入输出来说,很多时候浏览器给我们的信息更直观也更容易发现问题,这样看可能端到端的测试更适合我们,或者点一点,但是这样我们也很难发现代码内部的一些问题。
当然,比如你的处理很简单并且都和业务有关,逻辑计算通过一个请求都交给了后端处理;或者只做了一个展示界面,那么单元测试确实没有必要。
其次,为了以后可以快速定位bug和让别人接手起来更有信心的方面来看,单元测试在一些大型或者复杂的项目中确实有一定的必要。
前端单元测试到底测什么?
回到上一个问题,单元测试的重点在于单元,这也是前端单元测试的难点。现在我们大部分使用的框架大多把页面渲染和功能放到了一起,那些才是我们需要测试的单元?从相对的角度来说,一些不会经常变化的功能可以细分成单元进行测试:
1.公共函数
2.公共组件
越底层的代码越有测试的必要,因为UI的实现会依赖底层代码,例如我们可能用到的一些类似ramda、antd库,都会经过严格的单元测试,如果我们想要在项目中自己实现,就要对这样方法和组件进行测试,业务逻辑一般会跟着项目迭代和更新随时变化,写测试的意义不大。
单元测试的意义在哪里?
1.重构、重构、重构,重要的事情说三遍
TDD的具体实现就是通过红灯->绿灯->重构不断重复,一步一步去健壮我们的代码,所以单元测试的最大的意义也是为了我们今后可以重构我们的代码,只要保证测试的准确,就可以在重构中准确的定位到问题。同时也为以后的开发提供支持,在测试的基础上我们可以重构结构和业务功能。
2.单元测试是最好的注释
写注释是很多程序员都会忽略的一个步骤,或者改了代码你并不会记得去改注释,很多程序员会倾向于把变量名作为注释,但它无法很好的解释内部的逻辑,而测试会提示你那些步骤是可以通过、如何使用的最好文档,。更详细的规范了测试目标的边界值与非法值。
3.定位bug,减少bug
测试最直观的体现当然是与bug相关的,单元测试可以通过不同的条件来发现问题在哪里,在一些弱类型的语言中也避免了一些类型检查的低级错误,当然这个现在我们都用TypeScript做到了。
4.被迫的规范组织结构
可能平时我们会把一个方法写的很复杂、一个类写的很大,没有想过如何去组织结构,但如果你想到你即将的测试要如何写的时候,那可能你在开发前必须要想想哪些部分可以提出来了。
2.前端单元测试怎么写
先介绍几个在测试中我们需要值得注意和经常提到的一些概念:幂等,Mock,断言
幂等:对同一输入操作表现出相同的输出结果,不会随时间等因素表现出副作用。对于一个方法来说,幂等是编程中必然的。而在前端测试中,现在的框架也会涉及到组件的生命周期和渲染方式,我们也要注意UI的幂等,保证一个组件渲染的结果相同。
Mock: 对前端来说,Mock数据不仅仅包括参数的模拟,还可能涉及到页面交互的模拟;在前端一些函数的参数也可以是一个函数,如果不知道函数调用的情况这,也会使测试的难度增加。好的事情是现在这些我们都可以通过第三方的库去做到,比如enzyme和jest。
断言:判断代码的实际执行结果与预期结果是否一致,在JS中我们的断言方法只有console.assert,在实际项目中不是很多见,在测试的时候我们可以借助断言库进行更多方式的比较。
以下以jest、enzyme测试react为例
函数
对于一些基本带有返回的函数,我们一般可以直接通过断言它的返回值
// function add(num){ return num + 1}
expect(add(1)).toBe(2);
如果一个函数里面并没有返回,而是调用了一个回调函数,我们可以通过模拟函数来判断它是否如期调用就可以了
function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}
const mockCallback = jest.fn();
forEach([0, 1], mockCallback);
// 被调用
expect(mockCallback).toBeCalled();
// 被调用了两次
expect(mockCallback).toBeCalledTimes(2);
// 被调用时传入的参数是0
expect(mockCallback).toHaveBeenCalledWith(0);
异步的请求也可以看作是一个函数,我们可以用jest.mock的方法模拟请求进行测试。
组件
React中,我们测试的目的一般都是为了测试是否渲染了正确的DOM结构和业务逻辑。
公共组件一般是一些无状态的纯函数组件,测起来也相对简单
// 通过enzyme创建一个虚拟的组件
const wrapper = shallow(
<wrapperComponent />/
);
// 通过class观察组件是否成功渲染
expect(wrapper.is('.wrapper-class')).to.equal(true);
当然,有些组件我们还有通过props传入一些属性;state和一些方法;甚至一些生命周期
class wrapperComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
number: props.number
}
}
componentDidMount() {
console.log(this.state.number)
}
handleClick = () => {
let { number } = this.state;
this.setState({
number: number + 1
});
}
render() {
return (
<div className="wrapper-class">
<button onClick={this.handleClick}>+</button>
</div>
)
}
}
const wrapper = shallow(
<wrapperComponent number={0}/>/
);
// 测试props
expect(wrapper.props()).toHaveProperty('number',0);
// 测试生命周期
expect(wrapper.prototype.componentDidMount.calledOnce).toBe(true);
// 测试方法是否实现
wrapper.instance().handleClick();
expect(wrapper.state()).to.deep.equal({number: 1});
值得注意的是,组件内部嵌入了自组件也会增加我们的测试复杂度,因为shallow只做了浅层渲染,在考虑我们要做自组件测试的时候,应该采用深度渲染获取子组件,例如mount方法。shallow和mount的使用会影响事件的触发不同
高阶组件
React中你可能会涉及到高阶组件(High-Order Component),理解高阶组件,我们可以把High-Order 和 Component分开理解。高阶组件可以看作一个组件包含了另一组件,我们如果把外层的组件看作High-Order,里面包裹的组件看作普通的Component就好理解一些。
那么测试的时候,我们也可以把他们分开来写。
// 高阶组件 component.js
export function HocWrapper(WrapprComponent) {
return class Hoc extends React.Component {
state = {
loading: false
};
render() {
return <WrapperComponent {...this.state} {...this.props} />;
}
};
}
export class WrapprComponent extends React.Component {
render() {
return <div>hello world</div>;
}
}
//component.test.js
import { HocWrapper, WrapprComponent } from "./component.js";
const wrapper = mount(HocWrapper(WrapprComponent));
// 测试有loading属性
expect(wrapper.find('WrapperComp').props()).toHaveProperty('loading');
一般来说,为了测试,我们要文件里吧HocWrapper函数和我们的WrapprComponent组件分别都export出来,当然我们自己写的高阶组件都会这样做。
而我们在开发中会用到诸如Redux的connect,这也是一种高阶组件的形式,所以这时候为了测试,我们会在一个文件中export一个没有connect的组件作为测试组件,props作为属性传递进去。
状态管理
React中我们一般用Redux做状态管理,分为action,reducer,还会有saga做副作用的处理。
对于actions的测试,我们主要验证每个action对象是否正确(其实我觉得这个用TS做类型推导就相当于加了测试)
// action是否返回正确类型
expect(actions.add(1)).toEqual({type: "ADD", payload: 1});
reducer就是一个纯函数,而且每个action对应的reducer职责也比较单一,所以可以作为公共函数去做测试。我们主要测试的内容也是看是否可以根据action的type返回正确的状态。
reducer测试的边界条件一般是我们初始化的store,如果没有action匹配,就返回默认的store。
import { reducer, defaultStore } from './reducers';
const expectedState= {number: 1}
// 根据action是否返回期望的store
expect(reducer(defaultStore, {type: "ADD",1})).toEqual(expectedState);
// 测试边界条件
expect(reducer(defaultStore, {type: "UNDEFINED",1})).toEqual(defaultStore);
如果你用了redux,可能还会用一些库来创建并记录store里的衍生数据,组成我们常用的selector函数,我们测试的重点放在是否能组成新的selector,并且它是根据store的变化而变化。
// selectors.js
export const domainSelector = (store) => store.init;
export const getAddNumber = createSelector(
domainSelector,
(store) => {number: store.number + 1},
);
// selectors.test.js
import { getAddNumber } form './selectors'
import { reducer, defaultStore } from './reducers';
// 判断生成selector
expect(getAddNumber(store)).toEqual({number: 1});
// 判断改变store生成新的selector
reducer(defaultStore, {type: "ADD",1})
expect(getAddNumber(store)).toEqual({number: 2});
对于一些请求和异步的操作,我们可能用到了saga来管理。saga对于异步我们会分为正常运行和捕获错误去进行测试。
// saga.js
function* callApi(url) {
try {
const result = yield call(myApi, url);
yield put(success(result.json()));
return result.status;
} catch (e) {
yield put(error(e));
return -1;
}
}
// saga.test.js
// try
const gen = cloneableGenerator(fetchProduct)();
const clone = gen.clone();
const url = "http://test.com";
expect(clone.next().value).toEqual(call(myApi, url));
expect(clone.next().value).toEqual(put({ type: 'SUCCESS', payload: 1 }));
// catch 要跳到catch,就要让它错误
const error = 'not found';
const clone = gen.clone();
// 需要执行
clone.next();
expect(gen.throw('not found').value).toEqual(put({ type: 'ERROR', error }));
这里只对单元测试要测那些点做了阐述,如果希望了解详细的测试如何编写,Angular和Vue的CLI已经做的很好,也给出了适当的例子,对于React,请看这篇文章:https://github.com/Hsueh-Jen/blog/issues/1
3.踩过一些坑
一些window上的属性
跑测试的时候,我们并不是在浏览器上运行,所以一些window下的属性我们无法获取,我们常用的有localStorage这类的属性,会导致测试报错。
所以我们在本地应该自己模拟一个localStorage方法用于测试
function storageFunction() {
let storage = {};
return {
setItem: function(key, value) {
storage[key] = value || '';
},
getItem: function(key) {
return key in storage ? storage[key] : null;
},
removeItem: function(key) {
delete storage[key];
},
clear: function() {
storage = {}
}
};
}
箭头函数
如果使用箭头函数,需要对实例进行Mock,才能保证上下文环境。
网友评论