前端单元测试实践

1,551 阅读12分钟

单元测试一直以来都是让开发者又爱又恨的东西,相信在0202年,技术团队都意识到了单元测试的重要性,但是苦于开发时间的制约,和对于陌生领域的抵触心理,迟迟没有没付诸于行动。单元测试在开发初期确实会增加我们的整个开发周期,但是项目不断的成长,没有单元测试代码的维护风险也是不断递增的。大家可能没有办法一下子爱上单元测试,但是相信时间会向你证明单元测试的价值。

单元测试的定义及理解

先来谈谈个人对于单元测试的理解给出的定义是:程序自身开发者对于程序最小独立单元,通过程序模拟各种异常和边界条件,自动检测代码的执行结果是否符合预期。以保证程序质量,减少后期维护测试成本的一个过程。 所以单元测试具有以下的特征:

  1. 单元测试是程序开发者自己写的,不应该逃避的。
  2. 为了保证测试单元的独立性,要尽可能保证代码高内聚、低耦合。
  3. 单元测试不能只测试正确的结果,要通过测试用例让程序报错。
  4. 测试的过程不能有人工去参与,输入和输出不能手动去进行。
  5. 写单元测试可能会花费很多时间,但是从长远来看利大于弊。

单元测试的重要组成部分

抛开任何的技术栈和单元测试框架,一个单元测试应该包含哪几个部分呢?

  1. 测试框架:结合技术框架运行测试用例。
  2. 断言库:用于判断是否能够通过测试用例一般分为两种,使用assert的TDD断言风格和should、expect的BDD的断言风格。
foo.should.be("aa");
assert("mike" == user.name);
expect(foo).to.be("aa");
  1. Mock:用于屏蔽对于外部环境(接口、数据库等)的依赖,如 spy、stub、mock等
  • spy 用于监听函数是否被调用
  • stub 用于替换目标函数,自定义行为和结果,
  • mock 通过组合spies和stubs,使替换一个完整对象更容易
  1. 覆盖度统计:用于计算单元测试覆盖度,并生成单元测试报告 | 覆盖类型 | 说明 | |------|------------| | 语句(Stmts) | 语句是否执行,通常衡量指标 | | 分支(Branch) | switch if else 分支,尽可能覆盖全 | | 函数(Funcs) | 函数是否执行 | | 行(Lines) | 代码行是否执行 |

前端单元测试框架

工欲善其事必先利其器,使用一个优秀的单元测试框架,将会使你的单测事半功倍。

Jest · 令人愉快的JavaScript 测试

Jest的Facebook开源的单元测试框架,主要用于React和React Native的单元测试,前端的单元测试存在的一个难点就是如何对UI的结果进行自动化校验。Jest支持快照模式,通过生成快照高效对UI进行测试。Jest内置了Mock系统模块可以很轻松的对外部环境进行有效屏蔽,此外Jest还集成了Istanbul,计算单元测试覆盖度,生成测试报告。总之如果你使用的React技术栈,Jest将会是你最好的单元测试选择。

Enzyme模拟渲染组件

Enzyme是Airbnb开源的用于模拟渲染React和React Native组件的工具库,使用的是类似于Jquery的API方法接口,可以与Jest相辅相成,获取DOM节点、模拟DOM事件(simulate),达到测试组件内部逻辑的效果。 Enzyme支持三种渲染模式,根据不同的特性应用于不同的测试场景:

渲染方式特点应用场景
shallow渲染速度快、性能好、只渲染一层DOM节点模拟组件事件
mount执行组件全生命周期、时间较长检测生命周期、需要渲染子组件内容
render渲染HTML结构、无法涵盖全生命周期对比组件及子组件HTML内容

几种常见的单元测试场景及实践

utils工具纯函数

utils工具方法往往是一个纯函数,这类单元测试是最容易些的也是最重要的,一旦出现异常将会影响到多个业务模块,所以要优先保证这些代码的覆盖度。那么这部分单元测试该如何写呢

// 待测试方法
/**
 * 将秒时间长度转换成可阅读的文字
 * 3700 -> 1小时2分钟 (向上取整)
 * @param {Integer} seconds
 */
formatSeconds(seconds) {
    if(isNaN(seconds)) return '';


    let timeStr = '';
    let hours = Math.floor(seconds / (60 * 60));
    let minute = Math.ceil(seconds % (60 * 60) / 60);
    if(hours) {
        timeStr += hours + '小时';
    }
    if(minute){
        timeStr += minute + '分钟';
    }
    return timeStr;
}

// 单元测试用例
describe('formatSeconds:格式化秒时间方法测试', () => {
    test('时间大于1小时返回结果校验', () => {
        expect(timeUtil.formatSeconds(3700)).toBe('1小时2分钟');
    })
    test('时间等于1小时返回结果校验', () => {
        expect(timeUtil.formatSeconds(3600)).toBe('1小时');
    })
    test('时间小于1小时返回结果校验', () => {
        expect(timeUtil.formatSeconds(300)).toBe('5分钟');
    })
    test('参数不是一个数字类型 返回结果为空校验', () => {
        expect(timeUtil.formatSeconds('not a number')).toBe('');
    })
})

测试用例中的describe表示一组相同模块的测试用例,每一个test或it代表一个测试用例,需要注意的是每个测试用例中测试的点要保证单一性,测试用例也要尽可能考虑边界条件。每一个expect代表一个断言,断言最好要具有一定的语义性,这样便于问题的快速定位。jest提供了丰富的断言方法.

无状态通用组件

前端有一部分组件类似于util纯函数,只是其主要作用是用来渲染一些通用的UI样式,Jest为这种组件提供了快照的测试方法,每次都会以JSON文件的方式在本地生成一份快照文件,快速高效进行单元测试,例如通用组件Button:

describe('<Button/>', () => {
  test('Snapshot', () => {
    const component = renderer.create(<Button/>);
    let snapshot = component.toJSON();
    expect(snapshot).toMatchSnapshot();
  });


  test('disable Snapshot', () => {
    const component = renderer.create(<Button onPress={()=>{}} disabled={true}/>);


    let snapshot = component.toJSON();
    expect(snapshot).toMatchSnapshot();
  });
});

如要要对快照进行更新 可以使用如下命令参数

jest --updateSnapshot

Redux异步Action

在你的项目中一般都会使用Redux作为状态管理的工具,对于大部分Redux代码来说都是纯函数,不需要模拟数据,很好就行单元测试。对于使用Redux Thunk或其他异步中间件的异步Action,可以使用redux-mock-store来模拟store,校验dispatch的action是否正确

describe('getUserInfo获取用户信息异步Action验证', () => {
  beforeAll(() => {
    // 引入 redux-mock-store 对 store 进行 mock
    const middlewares = [thunk];
    mockStore = configureMockStore(middlewares);
    // mock HomeAPI接口方法
    jest.mock('@/apis/home');
  })
  test('获取用户信息成功结果验证', () => {
    const store = mockStore({ userInfo: {} });
    const expectedActions = [{ type: types.SET_USER_INFO, userInfo: { id: 1 } }];
  
    HomeAPI.getUserInfo = jest.fn().mockResolvedValue({
      user: {
        id: 1
      }
    });
  
    return store.dispatch(actions.getUserInfo()).then((res) => {
      expect(store.getActions()).toEqual(expectedActions);
    })
  });
})

此处还涉及到了单元测试的生命周期beforeAll用于在测试用例开始执行之前做一些初始化的操作,除了beforeAll其他的生命周期还有:

生命周期描述使用场景
beforeAll所有测试用例开始前统一做数据或对象的初始化
beforeEach每一个测试用例开始前每个用例都初始化一遍,避免测试用例之间相互影响
afterAll所有测试用例结束后统一恢复mock数据避免对其他的测试用例造成影响
afterEach每一个测试用例结束后恢复数据状态、避免同一组测试用例之间相互影响

另外测试用例中设计的对于外部接口的依赖,这里使用了Jest为我们封装的mock接口的方式,相当于前文中提到的stub的模式,模拟接口返回数据信息,详细可查看文档

不同参数组件渲染内容校验

组件中的某一个元素可能根据参数不同,展示不同的HTML内容,如果想对这部分内容定向进行测试的话,可以使用Enzyme模拟渲染组件,并通过API获取对应DOM节点的内容进行校验

import { render } from 'enzyme';
test('音频课显示结果校验', () => {
  const mockCourseData = {
    courseType: COURSE_TYPE.AUDIO_LIVE,
    courseMode: COURSE_MODE.SINGLE,
    startTime: null,
    liveStatus: null,
    planCourseCnt: null,
    audioLength: null,
    videoLength: null,
  };
  const component = render(<Message courseData={mockCourseData} />);
  expect(component.text()).toBe('语音直播 | 请到“微师”公众号学习');
});

此处由于需要获取Message内部子元素的内容,所以使用render的渲染方式,enzyme提供了很多类Jquery的DOM操作方法,React中可以通过class id等属性获取元素,但是由于在React Native节点中没有class id属性,使用起来可能不是很方便,但是可以为节点设置test-id属性唯一识别测试元素。

模拟组件点击事件监听函数调用情况

Enzyme另外一个黑科技便是可以通过simulate方法模拟DOM操作事件,这一方法可以与spy方法配合,测试DOM元素点击后是否触发了特定函数的执行。

test('下载失败 下载按钮点击可以触发 DownloadModule.start 方法校验', () => {
  const mockCourseData = {
    downloadType: DOWNLOAD_STATUS.ERROR,
  }
  const component = shallow(
    <Provider store={store}>
      <Download courseData={mockCourseData} />
    </Provider>
  );
  const spyFn = jest.spyOn(DownloadModule, 'start');
  component.simulate('press');
  expect(spyFn).toHaveBeenCalled();
});

次数由于点击事件只需触发在组件根元素,所以使用的是Enzyme效率最高的shallow方法渲染,然后通过simulate方法模拟触发press事件,检测目标函数是否被执行。

关于单元测试的几点思考

从上面的示例代码可以看出,其实单元测试写起来并不复杂,所写的单元测试的方法也就是那么几种,熟练了单元测试并不会像想象的那样占用太多的时间。看到这大家可能已经跃跃欲试了,别着急,动手之前我们先来思考几个问题。

单元测试的覆盖率是越高越好吗?

比较重视单元测试的团队可能会强制要求代码覆盖度达到较高的程度,甚至可以将单元测试集成至Devops流程中去,覆盖度不达标的代码无法被提交至代码仓库。但是现实情况是,部分代码并不适合来写单元测试,无法达到完全覆盖,过度强制要求覆盖度可能导致开发人员,只关注单元测试的量而忽略单元测试的质。

该在什么时候写单元测试? TDD or BDD

常见的两种单元测试模式是TDD(Test Driven Development)测试驱动开发和BDD(Behavior Driven Development)行为驱动开发。两者的差异在于TDD在开发者拿到需求之后首先来写测试用例,然后再进行代码程序的开发,最终的目标是使代码能够通过单元测试,这样能保证单元测试具有较高的覆盖度,而BDD是完成功能的开发,然后忽略代码内部实现考虑各种边界条件,模拟用户行为来写各种场景下的测试用例。虽然TDD可以很好地保证代码的质量和很高的测试覆盖度,但是TDD的开发模式不得不说存在一定的上手难度。 关于在什么时候写单元测试,一点建议就是,不要等待功能完成开发完之后再去补单元测试,因为大概率不会去补。最好的方式是在写对应的功能模块的时候同步来写单元测试,通过单元测试来自测代码功能是否正确,发现问题,通过补充单元测试的方式复现问题。相信通过这样的方式最终交付的代码质量将会有很大提高。

测试用例该怎么写?

  1. 单元测试要优先保证核心底层代码的覆盖度
  2. 写单元测试要忽略代码的内部实现,只考虑input output
  3. 要尽可能模拟真实数据,考虑测试数据的边界条件
  4. 测试用例遵循单一原则
  5. 写易于测试的代码,如果你的单元测试写起来很困难,那很可能你的代码设计不合理 ......不断补充中

前端单元测试实践策略

到这里大家可能还有一个疑虑就是如果要写单元测试的话,我该以什么策略给那些代码添加单元测试,不同类型的代码覆盖度的要求是怎样的。其实很难归纳出一个绝对准确的实践策略,就像一个老话说的一样鞋子大不大只有脚知道。这里根据其他技术团队的单测实践和主观的一些臆断,给出一个参考的实践策略。要想找到合适自己的鞋,还得自己下地走走。

代码类型测试策略重要程度
utils通用方法没有外部依赖的纯函数要求100%覆盖☆☆☆☆☆
纯UI公共组件通过快照方式测试☆☆
具有业务逻辑的components通过Enzyme校验UI渲染结果或事件触发响应,优先保证覆盖核心的代码逻辑☆☆☆☆
Action(creator) 层具有业务逻辑的异步Action需要覆盖各个异常分支条件☆☆☆☆
Reducer具有业务逻辑的复杂Reducer需要100%覆盖☆☆☆☆☆

单元测试准则

前端性能优化有雅虎35条军规,Geotechnical对于单元测试提供了27条准则供广大开发团队参考借鉴,相信看了这份准则将使你对单元测试有一个更为深刻的理解,当然也可以根据实践的经验制定出属于自己的单元测试准则。

总结

正如27条准则中的最后一条:了解单元测试的局限性,单元测试不是银弹,一个跑失败的测试可能表明代码有错误, 但一个跑成功的测试什么也证明不了。现实开发工作中,你有无数个理由放弃写单元测试。但能把单元测试坚持下来,看着慢慢变绿的单测报告也是一个很有成就感的事情。希望大家在单元测试的路上越走越远,加油~

参考资料