为什么谈前端单元测试

作者: 王彩彩的胖狗子 | 来源:发表于2019-05-14 15:31 被阅读1次

    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

    相关文章

      网友评论

        本文标题:为什么谈前端单元测试

        本文链接:https://www.haomeiwen.com/subject/lygsaqtx.html