加载中...
  • 前端单元测试介绍

    前言:公司团队希望前端做单元测试,说起来干前端这么长时间以来还从来没写过单元测试,网上找了些资料,what?这都是些什么鬼,发现资料好少,熬了好几天总算是了解了个大概怎么做了

    一、前端单元测试是什么

    1. 为检测特定的目标是否符合标准而采用专用的工具或者方法进行验证,并最终得出特定的结果。

    2. 对于前端开发过程来说,这里的特定目标就是指我们写的代码,通过写的测试用例检查的结果展示测试是否通过或者给出测试报告,这样才能方便问题的排查和后期的修正

    3. 对于给定的输入,单元测试检查结果。通过及早发现问题并避免 bug 回归,它可以帮助我们确保代码的各个部分按预期工作。

      二、为什么需要单元测试

      写单测和后期维护是需要一定成本的,我们一般只针对核心底层的模块书写单元测试。单元测试的好处如下:

    4. 减少 Bug,提升代码质量

    5. 提升代码的可读性、可维护性

    6. 为系统重构做铺垫

    三、单元测试覆盖率建议

    覆盖率可以简单理解为已被测试代码,具体分为行级、分支级、方法级等不同级别。它可以从一定程度上衡量我们对代码测试的充分性。原则上我们追求的单元测试覆盖率目标是100%,但业务场景多的情况几乎是不可能

    目前只针对核心底层的模块书写单元测试,如:公共函数和组件

    平台类项目,核心复杂功能尽量覆盖率做到最高,业务类的酌情处理

    目标覆盖率:

    行覆盖率(line coverage):表示是否每一行都执行 80%
    函数覆盖率(function coverage):表示是否每个函数都调用 100%
    分支覆盖率(branch coverage):表示是否每个if代码块都执行 80%
    语句覆盖率(statement coverage):表示是否每个语句都执行 80%

    四、前端单测规范约定

    以下仅作为参考,实际还需要按照各自项目进行评估。
    在单测工作开展前,需要先约定好单测相应的一系列规范。
    

    1. 测试文件统一在 src/tests 目录中维护 或者 与组件同级目录 如 login.test.tsx 跟 login.tsx 文件同级

    RPO1c4.jpg

    2. 测试文件命名与React组件命名保持一致,后面以.test.js结尾

    .test.ts .test.tsx 也可以看项目中是否使用ts

    3. 测试用例使用it(“功能描述”,()=>{})函数描述用例单元

    针对最小功能单元的测试用例主要集中在该函数内 尽量一个测试用例只做一件事情

    4. 一组功能集合测试使用describe(“功能集合描述”,()=>{})函数描述功能集合

    一个测试文件只能描述一个功能集合,这个功能集合可以是一个React组件,也可以是一个公共模块,公共函数,公共配置

    如下格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { add , minnus } from '../../src/common/index'

    // 尽量每次编写测试用例都用describe包裹进行分块
    // 每个测试用例一个it函数代表
    // 参数:
    // 字符串,代表测试用例名称:常用命名模式“被测对象在什么情况下是什么行为”
    // 函数,实际测试用例过程
    describe('测试common/index 文件相关代码', () => {
    // 测试用例
    it('调用 add方法执行 1+1=2',()=>{
    // 测试调用后的预期值为2
    expect(add(1,1)).toBe(2)
    })
    it('调用 minnus方法 执行1-1=0',()=>{
    // 测试调用后的预期值为0
    expect(minnus(1,1)).toBe(0)
    })
    })

    5. UI测试套件统一使用enzyme

    使用enzyme可以借助jquery like的选择器方便的对DOM渲染结果做校验

    6. React组件测试用例必须包含

    1. Snapshot快照比对
    2. Props传入
    3. 组件分支渲染逻辑
    4. 事件调用和参数传递
    5. 函数调用,state状态值的改变
    6. 页面跳转回应
    针对这一点我们可以根据这些维度来对我们的代码进行测试
    1. 某个子组件,标签,CSS class类 在组件中的个数,长度
    2. 某个标签下文本内容是否一致
    3. 标签类型
    4. 组件中函数调用是否符合预期,模拟调用该函数给定参数能否与预期结果一致
    5. 针对公共js库模块进行快照测试,确保当次更改是否需要
    6. 在执行某些操作后state的状态值是否发生改变,某个标签元素是否熏染

    五、单元测试框架技术选型

    Jest 简介

    Jest是 Facebook提供的一款轻量级 JavaScript :测试框架,它具有特点:

    1. 开箱即用,配置少,API简单,上手成本极低在沙箱中运行,更加安全
    2. 支持断言和仿真
    3. 自动生成测试覆盖率报告
    4. 通过生成Snapshot 进行 UI 测试单测执行效率,

    Enzyme 简介

    1. 专门用于React 测试工具,
    2. 方便操作 Dom 且操作风格模拟了jQuery的APi,比较直观,学习使用都比较简单
    3. 便利的工具函数库封装,可以处理浅渲染,静态渲染标记以及DOM渲染。

    六、单元测试实践

    1. UMI中单元测试环境搭建(安装和配置 Enzyme)

    首先安装 Enzyme 和相应的 React 适配器:

    1
    npm i --save-dev enzyme enzyme-adapter-react-16

    我们需要配置一下 Enzyme,才能在 Jest 测试文件中使用它。创建 src/.test.js ,代码如下:

    1
    2
    3
    4
    import { configure } from 'enzyme';
    import Adapter from 'enzyme-adapter-react-16';

    configure({ adapter: new Adapter() });

    2. 单元测试示例

    js函数相关

    1. 针对一个公共.js文件中的方法进行测试

    src目录下新建一个common/index.js开始添加一个简单的方法 代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function add(a,b){
    return a+b;
    }
    function minnus(a,b) {
    return a-b;
    }
    module.exports={
    add,
    minnus
    }

    在跟目录新建目录 test/common/index.test.js 用于自动测试index.js 中的方法 代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { add , minnus } from '../../src/common/index'

    // describe: 定义一个测试套件
    // it:定义一个测试用例
    // expect:断言的判断条件
    // toBe:断言的比较结果
    describe('测试common/index 文件相关代码', () => {
    it('调用 add方法执行 1+1=2',()=>{
    // 断言的判断条件 测试调用后的预期值为2
    expect(add(1,1)).toBe(2)
    })

    it('调用 minnus方法 执行1-1=0',()=>{
    // 测试调用后的预期值为0
    expect(minnus(1,1)).toBe(0)
    })
    })
    2. 测试异步代码

    代码示例:fetechData.js文件代码

    1
    2
    3
    4
    import request from "umi-request";
    export const fetchData = ()=>{
    return request.get("http://mock-api.com/RKDx59Ka.mock/test")
    }

    对应的测试用例fetechData.test.js文件代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import  { fetchData}  from './fetechData'

    test('测试 fetchData返回结果为{ code: 0, data: { list: [], state: false } }',async ()=>{
    await fetchData().then((res)=>{
    const obj = { code: 0, data: { list: [], state: false } };

    // tomatchobject检查一个JavaScript对象是否匹配一个对象的属性子集
    expect(res).toMatchObject(obj);
    })
    })

    react组件相关

    1.示例

    新建一个enzyme.jsx的测试文件。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    import React, { Component } from 'react';

    class Example extends Component {
    state = {
    strArr:'张三',
    undoList: [], // 搜索框value值
    value:'', // 搜索框value值
    }

    btnFn = ()=>{
    this.setState({
    strArr:'李白'
    })
    }

    // 页面跳转
    linkLocation = ()=>{
    window.location.href = 'https://www.baidu.com/'
    }

    // 监听input输入框改变 state 下 value值
    handleInputChange = (e) => {
    this.setState({
    value:e.target.value
    })
    }

    // 监听键盘回车事件 根据是否有值进行一系列操作
    handleInputKeyUp = (e) => {
    const { value } = this.state;

    if(e.keyCode === 13 && value){
    // 接收父组件传递过来的函数
    this.props.addUndoItem(value);

    // 设置完值后情况文本
    this.setState({
    value:''
    })
    }
    }

    render() {
    const {label, title, text} = this.props;
    const { value } = this.state;
    return (
    <div>
    {
    label ? (<label>{label}</label>) : null
    }
    <div id="title">{title}</div>
    <button data-test='btn' onClick = { this.btnFn }>{text}</button>
    <div>
    输入框:
    <input
    placeholder='输入输入1'
    className='header-input'
    data-test='input'
    value = { value }
    onKeyUp = { this.handleInputKeyUp }
    onChange= { this.handleInputChange }></input>
    </div>
    </div>
    )
    }
    }
    export default Example

    enzyme.test.js的测试文件代码如下

    1. 针对父组件给子组件传值,测试文本显示是否符合预期 Props是否正确传入

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      import React from 'react'
      import Enzyme from 'enzyme'
      import Adapter from 'enzyme-adapter-react-16'
      import Example from './enzyme'

      const { shallow } = Enzyme
      Enzyme.configure({ adapter: new Adapter() })

      describe('Example 组件相关', ()=> {
      it('测试组件传值 文本是否符合预期', ()=> {
      const btnName = '按钮名';
      const title = '标题';

      // shallow 会将一个组件渲染为虚拟的 DOM 对象
      let wrapper = shallow(<Example btnName={btnName} title={title} />);

      // 判断名称是否跟标签文本名称一致
      // 根据选择器查找节点,找到渲染树中的节点。
      // toBe 对比是否符合预期
      expect(wrapper.find('button').text()).toBe(btnName);
      expect(wrapper.find('#title').text()).toBe(title);
      })
      })
    2. 测组件分支渲染。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      it('测试label 组件不渲染情况', () => {
      const wrapper = shallow(<Example />);
      // 根据选择器长度判断
      expect(wrapper.find('label').length).toBe(0);
      });
      it('测试label 组件渲染', () => {
      const wrapper = shallow(<Example label='文本'/>);
      expect(wrapper.find('label').length).not.toBe(0);
      });
    3. 测试事件点击,state下的状态值有无发生改变

      1
      2
      3
      4
      5
      6
      7
      8
      it('测试事件函数调用,state下的状态值有无发生改变', () => {
      const wrapper = shallow(<Example text='按钮' />);
      const btnElem = wrapper.find("[data-test='btn']");
      // 模拟事件点击操作
      btnElem.simulate('click');
      // 输入state下的strArr值是否符合预期更改
      expect(wrapper.state('strArr')).toBe('李白');
      });
    4. 测试执行组件中某个函数,state下的状态值发生改变

      1
      2
      3
      4
      5
      6
      7
      it('测试执行组件中某个函数,state下的状态值发生改变', () => {
      const wrapper = shallow(<Example text='按钮' />);

      wrapper.instance().btnFn(); // Example组件中调用 btnFn 方法
      // 输入state下的strArr值是否符合预期更改
      expect(wrapper.state('strArr')).toBe('李白');
      });
    5. 测试input框输入,监听onKeyUp,onChange 事件的处理,接收父组件传递过来的值进行操作是否符合预期

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      it('Example 组件 input 框内容,当用户输入时,会跟随变化', () => {
      const wrapper = shallow(<Example />);
      const inputElem = wrapper.find("[data-test='input']");
      const userInput = 'w晚风';

      // simulate 模拟 触发input框的 change事件
      inputElem.simulate('change',{
      target:{
      value:userInput
      }
      })

      // 调用Header中的state,对比state下的value值是否跟上面模拟相匹配
      expect(wrapper.state('value')).toEqual(userInput); // 这里是对用户操作后组件里的数据做测试
      });

      it('Example 组件 input 框输入回车时,如果 input 无内容,无操作', () => {
      const fn = jest.fn(); // 这是jest的一个模客方法

      const wrapper = shallow(<Example addUndoItem = { fn } />);
      const inputElem = wrapper.find("[data-test='input']");
      // 先对组件state里的value重置为空
      wrapper.setState({value:''})
      inputElem.simulate('keyUp',{
      keyCode:13
      });
      // 在没用内容的情况下不调用函数
      expect(fn).not.toHaveBeenCalled()
      });

      it('Example 组件 input 框输入回车时,如果 input 有内容,函数应该被调用', () => {
      const fn = jest.fn(); // 这是jest的一个模客方法

      const wrapper = shallow(<Example addUndoItem = { fn } />);
      const inputElem = wrapper.find("[data-test='input']");

      wrapper.setState({ value:'w晚风' });
      // 模拟键盘回车事件
      inputElem.simulate('keyUp',{
      keyCode:13
      });

      // 在有内容的情况下 函数 应该被调用
      expect(fn).toHaveBeenCalled();

      // 在input框有内容的情况下,最后执行完方法后 应该清楚掉文本内容
      const inputElem2 = wrapper.find("[data-test='input']");
      expect(inputElem2.prop('value')).toBe('');
      });
    6. 测试组件中有个函数linkLocation()执行后进行页面跳转,对比执行后,当前页面地址是否符合预期

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      it('测试组件中有个函数执行后进行页面跳转,对比执行后,当前页面地址是否符合预期', () => {
      const wrapper = shallow(<Example />);

      // 这创造了一个具有所有原始功能的位置,但它是可模拟的:
      const location = window.location;
      delete global.window.location;
      global.window.location = Object.assign({}, location);

      wrapper.instance().linkLocation(); // 执行linkLocation()方法
      // 判断当前的地址值是否符合预期
      expect(window.location.href).toBe('https://www.baidu.com/');
      });
    7. 对组件进行快照测试

      1
      2
      3
      4
      5
      6
      it('Header 渲染样式正常', () => {
      const wrapper = shallow(<Example />);

      // expect(wrapper).toMatchSnapshot();// 进行快照保存
      expect(wrapper.debug()).toMatchSnapshot();
      });

      当执行过一次快照后,下次执行会检查当前组件与上次对比是否发生改变,防止手误更改

      2. 覆盖率

    修改package.json添加执行命令
    最后,跑一遍所有的测试用例,均是通过的。测试覆盖率100%

    1
    2
    3
    "scripts": {
    "test": "umi-test --coverage"
    },

    然后执行npm run test 跑单元测试的在跟目录会生成一个coverage目录,

    通过打开这个HTML文件再浏览器查看 就可以看到整个项目的测试报告了

    RPO3jJ.jpg

    如:

    RPOGu9.jpg

    3. enzyme 三种渲染方式

    Enzyme为开发者提供了三种渲染方式

    1. shallow:浅渲染,将组件渲染成虚拟DOM对象,只会渲染第一层,子组件将不会被渲染出来,因而效率非常高。不需要DOM环境, 并可以使用jQuery的方式访问组件的信息

    2. render:静态渲染,但不依赖DOM API,而是渲染成HTML结构,并利用cheerio实现html节点的选择,它相当于只调用了组件的render方法,得到jsx并转码为html,所以组件的生命周期方法内的逻辑都测试不到,所以render常只用来测试一些数据(结构)一致性对比的场景

    3. mount: 完整渲染,用于将React组件加载为真实DOM节点,它会生成完整的DOM节点,所以可以测试子组件。但是要依赖一个用jsdom模拟的浏览器环境。

    三种方法中,shallow是最快,但是shallow有局限性,shallowmount因为返回的是DOM对象,可以用simulate进行交互模拟,而render方法不可以。一般shallow方法就可以满足需求,如果需要对子组件进行判断,需要使用render,如果需要测试组件的生命周期,需要使用mount方法。

    单元测试代码demo

    https://gitee.com/RocOSC/react-test-demo
    https://gitee.com/wxialexiatian/umi-test-demo

    上一篇:
    taro如何获取路径传参
    下一篇:
    Jest 简单介绍与使用
    本文目录
    本文目录