为什么谈前端单元测试

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,才能保证上下文环境。

参考资料

https://cn.redux.js.org/

https://doc.ebichu.cc/jest/docs/zh-Hans/api.html

https://zhuanlan.zhihu.com/p/55960017

推荐阅读更多精彩内容