KevinHu-1024/kevins-blog

再谈前端单元测试

KevinHu-1024 opened this issue · 0 comments

单元测试的意义

  1. 作为现有代码行为的描述
  2. 对发现的bug补充测试代码,防止再次出现
  3. 强迫开发者写可测试的代码,一般来说可测试的代码可读性也比较好
  4. 依赖的组件如果有修改,受影响的组件能在测试中发现错误

下面是一个典型的组件测试:

describe('测试权限高阶组件', () => {
  it('当组件被权限组件包裹,且处理函数返回true时,组件被渲染', () => {
    const handleFunc = () => true
    const wrapper = shallow(<Btn
      customHandler={handleFunc}
      permissionsName={[]}
      otherProps=""
      currentUserPermissions={currentUserPermissions}
    >按钮</Btn>)
    expect(wrapper.contains(<Button otherProps="">按钮</Button>)).toBe(true)
  })
  it('当组件被权限组件包裹,且处理函数返回false时,组件不渲染', () => {
    const handleFunc = () => false
    const wrapper = shallow(<Btn
      customHandler={handleFunc}
      permissionsName={[]}
      otherProps=""
      currentUserPermissions={currentUserPermissions}
    >按钮</Btn>)
    expect(wrapper.contains(<Button otherProps="">按钮</Button>)).toBe(false)
  })
  it('当没有自定义判断函数传入时,组件应该使用hasPermission函数作为this.permissionHandler', () => {
    const wrapper = shallow(<Btn
      permissionsName={[]}
      currentUserPermissions={currentUserPermissions}
    >按钮</Btn>)
    expect(wrapper.instance().permissionHandler).toBe(hasPermission)
  })
  it('当使用自定义判断函数时,函数的参数应该是[组件权限[], 当前用户所有权限[]]', () => {
    const handleFunc = jest.fn()
    const componentPermissions = ['edit_site']
    shallow(<Btn
      customHandler={handleFunc}
      permissionsName={componentPermissions}
      otherProps=""
      currentUserPermissions={currentUserPermissions}
    >按钮</Btn>)
    expect(handleFunc.mock.calls[0][0]).toBe(componentPermissions)
    expect(handleFunc.mock.calls[0][1]).toBe(currentUserPermissions)
  })
})

敏捷开发下单元测试的意义

小瀑布流还是敏捷开发?

下面两张图,哪一张表示敏捷开发?
image
image

不断重构

敏捷开发意味着要经常交付可运行的软件,这个软件不一定是完美的,在早期交付的软件甚至没有架构可言,之所以在早期淡化架构,是因为此时软件足够小,足够简单。

这就意味着在下一个迭代周期内,我们势必要对这个软件进行重构,来满足功能的复杂性,架构的合理性,甚至是在保证外部行为不变的前提下,重构大部分代码。

那么问题来了,如何在数十次重构过程中,保证我以前交付的功能不会受到影响呢?那么单元测试就是记录软件行为的方案之一。没有单元测试,就只能走小瀑布流开发,无法走敏捷开发,因为重构没有质量保证。

工具链与参考项目

使用create-react-app可以快速创建一个具备单元测试支持的前端项目,其使用的工具链为Jest,为了测试React组件更加方便,我们需要参考其文档,把Enzyme集成进来。

我这里使用create-react-app生成了项目,并集成了Enzyme,可以参考我的commit看具体文件变动,很简单

一般而言,一个测试由Runner断言库假函数(模拟函数)组成

Runner

Runner是用来执行测试用例的程序,对用例进行分组、提供一些测试周期钩子进行环境准备和清理、决定跑用例的顺序等等,是整个测试程序的骨架,一般来说当你看到describeittestbeforeEach等函数名时,这块就是Runner掌管的部分(Runner不同,刚才提到的函数名也不同,但意义是差不多的),一般来说一段测试代码的骨架长这个样子(使用Jest作为Runner,当然还有一些更通用的、知名的Runner —— Mocha):

describe('用例分组名称', () => {
  it('第一个测试用例', () => {
    // 至于里面些什么随意,就算不是测试代码也一样可以跑
  })
  it('第二个测试用例', () => {
    
  })
})

Jest提供的有关Runner方面(它还提供了断言等功能,这里只说Runner)的全局函数参见文档

断言库

断言库就是一系列用来判断值的函数库,比如相等、全等、字符串包含、布尔判断、对象浅层判断、深层判断等,我们所熟知的断言库 —— Chai就是做这个功能的。不过在这里,为了使用方便,减少集成成本,我们使用了Jest提供的内置断言库,它所提供的断言函数文档在这

断言库的行文风格一般有两个流派,TDDBDD这里面的TDD和BDD只能理解为"风格",与你是否使用测试驱动开发TDD行为驱动开发BDD没什么关系,你在测试驱动开发流程中使用BDD风格的断言库是没有问题的。一般而言,BDD风格的断言库更接近于自然语言,读起代码来比较流畅。

典型的TDD断言风格如下(Chai):

// 判断相等
assert.equal(3, '3', '== coerces values to strings')

典型的BDD断言风格如下(Chai),BDD还包含两种风格expectshould

// 判断相等expect风格
expect(1).to.equal(1)

// 判断相等should风格
foo.should.equal('bar');

关于expectshould的区别,可以参考这里

Jest内部提供的断言库为BDD-expect风格,它所提供的断言函数文档在这

假函数(模拟函数)

假函数是做函数行为判断的,当你想知道某个函数是否被调用了、调用了几次、每次调用的参数是什么的时候,假函数就派上用场了。在通用测试场景中一般使用sinon这个假函数库。不过还是为了使用方便,减少集成成本,我们使用了Jest提供的内置的假函数库来做这件事。

// 比如,我想测试A函数在测试用例中是否以A(param1, param2)调用
function A(p1, p2) {
  // do something...
}
A = jest.fn()  // 使用假函数替换了A原始函数
// 调用A代码....如A(参数, 参数)
// 可以拿到A的每一次调用记录
A.mock.calls
// 可以拿到A的第一次调用记录的第一个参数
A.mock.call[0][0]
// 断言
expect(A.mock.calls[0][0]).toEqual(param1)
expect(A.mock.calls[0][1]).toEqual(param2)

由于我们替换了原始A函数,原始A函数的行为都不会执行,我们只是测试传入值是否正确。
想要测试A函数正不正确,需要真实的去测试A函数,那是另一层测试了。这里的算是测试调用A函数的函数的行为是否正确,给A传参是否正确。

Jest内置的假函数库文档在这里

Enzyme

Enzyme是测试React组件的专用工具,它出现的意义是帮助我们简化React组件测试的过程。

如果不用Enzyme我们这样测试React组件:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

it('renders without crashing', () => {
  const div = document.createElement('div');
  ReactDOM.render(<App />, div);
});

使用了Enzyme我们这样测试React组件:

import React from 'react'
import { shallow } from 'enzyme'
import App from './App'

describe('单元测试1', () => {
  it('App组件正常渲染', () => {
    shallow(<App />)
  })
})

我们知道React帮助我们接管了DOM操作,我们只需要关注数据:state、props、自己实现的组件实例上的各个函数、最终render出的组件树是否正确就行了,至于用一个个选择DOM树来确定是否渲染正确,就没有必要了。基于这个目的,Enzyme为我们提供了大量的用来访问React组件实例的便捷方法,来辅助我们进行判断,大大减少了选择器的使用。下面是几个简单的例子(Jest + Enzyme):

  1. 测试一个组件中是否包含另一个组件,使用.contains()
import { shallow } from 'enzyme'
import A from './A'
import B from './B'

it('A组件应该包含B组件', () => {
  const wrapper = shallow(<A />)
  expect(wrapper.contains(<B />)).toBe(true)
})
  1. 测试组件this上的属性,使用.instance()
// 比如A组件长这个样子
class A extends React.Component {
  constructor(props) {
    super(props)
    this.someProp = 'a'
  }
  render() {
    return <div>text</div>
  }
}

// 测试
import { shallow } from 'enzyme'

it('A组件实例上的属性someProp等于a', () => {
  const wrapper = shallow(<A />)
  expect(wrapper.instance().someProp).toBe('a')
})
  1. 测试组件的prop,使用.prop(key).props()[key]
// 比如A组件长这个样子
class A extends React.Component {
  static propsTypes = {
    text: PropTypes.string,
  }
  constructor(props) {
    super(props)
  }
  render() {
    return <div>{text}</div>
  }
}

// 测试
import { shallow } from 'enzyme'

it('A组件的text prop等于'a'', () => {
  const wrapper = shallow(<A text="a" />)
  expect(wrapper.prop(text)).toBe('a')
  // 或
  expect(wrapper.props().text).toBe('a')
})
  • 关于shallowmount的选择

    以A组件为例,shallow只渲染A组件render下出现的组件,如果render下有<B />,B组件的render下还有<C />,那么B组件不会递归继续渲染出C来,使用contains不会检测到C组件,好处是,测试环境比较纯净,速度比较快,缺点是无法测试到C组件对A的影响。mount则是完整渲染出C,速度较慢,当你想要测试C与A的关系的时候可以使用mount

更多的例子参见Enzyme文档,这个文档中的例子使用的是Mocha + Chai + Enzyme的方案,api稍有不同,但是语义基本一样,在Jest中找到相应api替换使用即可。

单元测试的测试策略

由于单元测试时间成本比较高,测的越细则时间越长。所以大家可以根据项目的情况,选择适合自己项目的测试深度,下面的几个层次供参考:

Stage1:保证覆盖率,所有组件渲染不报错

这里是最基础的测试策略,目的只是为了在及其有限的时间下或历史遗留代码较多、拆分不好的条件下,迅速接入前端单元测试,并保证一定的覆盖率(当然这个覆盖率并没有太大的意义,因为没有具体的测试)。

这个策略会对组件进行简单的默认渲染操作,不报错即为通过。本身默认条件下的正确渲染就是测试用例,不包括任何断言、模拟过程。

缺点:测试粒度很粗,只能发现最容易发现的问题,只适合刚开始接入前端单元测试团队做过渡使用。

例子如下:

class A extends React.Component {
  static propTypes = {
    text1: PropTypes.string.isRequired,
    text2: PropTypes.string,
  }
  static defaultProps = {
    text2: '',
  }
  render() {
    const { text1, text2 } = this.props
    return <div>必填参数: {text1}, 选填参数: {text2}</div>
  }
}

import { shallow } from 'enzyme'

it('A组件正常渲染', () => {
  shallow(<A text1="a" />)
})

Stage2:Stage1 + 公共函数测试,公共组件测试

这个就是比较适中的一个测试策略,经过大量测试用例的测试,保证公共部分的质量及可靠性,同时,不深入业务,日常迭代时对测试代码改动较少,是比较推荐的通用的测试策略。

缺点是初期要花一定时间写测试代码,新项目边测编写还比较容易,如果是老的遗留项目,可能需要下很大力气去写(因为内部的补丁太多,对外依赖较多,函数很大难于测试等因素,而且由于是先写代码后写测试,可能导致某些情况难以覆盖到位)

以按钮组件为例:

class Button extends React.Component {
  static propTypes = {
    text: PropTypes.string.isRequired,
    handler: PropTypes.func,
  }
  static defaultProps = {
    handler: null,
  }
  handleClick = (e) => {
    const { handler } = this.props
    if (!handler) {
      return false
    }
    handler(e)
  }
  render() {
    const { text } = this.props
    return <button onClick={this.handler}>{text}</button>
  }
}

import { shallow } from 'enzyme'

it('默认情况下正常渲染', () => {
  shallow(<Button text="按钮" />)
})

describe('功能测试', () => {
  it('传入的文字应该选渲染出来', () => {
    const wrapper = shallow(<Button text="按钮" />)
    expect(wrapper.contains('按钮')).toBe(true)
  })
  it('当传入处理函数时,handleClick应该调用它,并正确传参', () => {
    const handler = jest.fn()
    const wrapper = shallow(<Button2 text="按钮" handler={handler} />)
    const e = {}
    wrapper.instance().handleClick(e)
    expect(handler).toHaveBeenCalled()
    expect(handler.mock.calls[0][0]).toEqual(e)
  })
  it('当没传处理函数时,handleClick应该返回false', () => {
    const wrapper = shallow(<Button2 text="按钮" />)
    const e = {}
    const res = wrapper.instance().handleClick(e)
    expect(res).toEqual(false)
  })
})

测试公共函数的话,就不需要用Enzyme了,直接使用断言库就足够了。

Stage3:Stage1 + Stage2 + 测试业务组件的自定义函数

这个测试会针对经常变动的业务组件中的自定义函数进行测试,尽可能挑选不变的函数进行测试,防止由于业务变更导致用例失效

缺点:测试用例不太稳定,适合增量业务场景下保证迭代质量,不适合经常大改动的业务

Stage4:Stage1 + Stage2 + Stage3 + 测试业务组件的业务功能

这个测试会更加精细一些,针对业务组件的业务进行测试

缺点:测试用例很不稳定,受业务变动影响,失去了稳定的检测基础。需求改变,用例将大幅修改或作废,需要重新积累测试用例。由于很少存在迭代的过程(都是直接替换),单元测试的意义不明显,大量时间被浪费了,适合增量业务场景下保证迭代质量,不适合经常大改动的业务。另外由于业务经常涉及其他系统及异步场景,环境不纯净,测试困难

Stage0:补测试

补测试是在整个软件生命周期中随时都应该进行的过程,当测试同学或自己发现代码存在bug单元测试有没有报错时,应该按照bug产生的条件尽可能的在单元测试中写出模拟,并且报错然后再去组件代码中将bug改正,此时单元测试应该为通过状态,这样就保证了bug被记录下来,在以后的重构中不会再次出现。

当然,如果受到测试环境等限制,导致单元测试无法模拟bug的产生,此时应责成测试同学补充自动测试用例来模拟bug。

单元测试的原则

易于替换的部件

组件中的函数应该易于测试,易于替换(替换为jest.fn()),一个坏例子:

const funcA = () => {}

class Tab extends React.Component {
  // some code
  someHandler() {
    // ...
    funcA()
  }
  // some code
}

在上面的例子里面,如果我们有场景需要测试someHandler是否对funcA正确传参时,我们在测试代码中很难将funcA替换成jest.fn,因为funcA已经被硬编码进了函数体,造成无法测试。

解决方案:将外部依赖管理在组件的constructor中:

const funcA = () => {}

class Tab extends React.Component {
  // some code
  constructor(props) {
    super(props)
    this.depFunc = funcA
  }
  someHandler() {
    // ...
    this.depFunc()
  }
  // some code
}

// 测试代码中的函数替换
it('测试someHandler向depFunc传参', () => {
  const instance = shallow(<Tab />).instance()
  const someHandler = instance.someHandler
  // 替换
  instance.depFunc = jest.fn
  // 执行
  someHandler()
  // 断言传参
  expect(instance.depFunc.mock.calls ...........)
})

只测自己写的

只测试自己写的部分,第三方库的行为已经由他们自己的单元测试保证了,我们不应该再去测试。比如给组件的onClick绑定了处理函数,那么就不应该去测试:点击之后处理函数是不是执行了。

尽可能不测试业务

业务多变,测试用例难以稳定,应交由自动化测试最终结果就可以了

样式由自动化截图测试来测

单元测试中不进行样式测试,自动化测试中有截图比较技术,更加方便易维护

单元测试与自动化测试的边界

image

异步/Promise测试

我们不测试http请求,这块一般由第三方库来保证。而且受测试环境的限制,也很难进行http测试。这里的异步主要是一些非http请求的异步函数测试。

it('异步', async() {
  await expect(xxx异步函数()).........
})

Jest的限制

Jest默认使用js-dom库来进行测试环境模拟,这个库需要打一系列Polyfill才能工作,在create-react-app中这些工作已经做好,直接使用即可,日后需要增加Polyfill,可在相应文件中进行添加。

单元测试与覆盖率

单元测试与覆盖率是两码事,覆盖率高的代码,未必单元测试写得好。以我们的测试策略1为例,即使一句断言都不加,覆盖率也能跑到20%-40%。