聊聊前端开发的测试
发布于 8 年前 作者 zengliqi 4012 次浏览 来自 分享

本文作者: Coding 工程师 Candy Zheng

最近在做 Coding 企业版 前端开发时花了很多时间写测试,于是和大家分享一些前端开发中的测试概念与方法。

前端测试

什么是写测试代码

我理解的写测试其实是你写一些代码来验证你所谓的可以交付的代码是你所预期的设计,有一些朋友叫他 TDD 也就是测试驱动型的设计,其实到底是先写代码还是先写测试,并不是最重要的,倒是能给你信心这个代码是符合设计的更重要。

为什么要测试,前端需要测试么

这个问题不是这篇分享要和大家聊的,但是作为曾经也有这样疑问的我还是简单提一下。我们经常过于自信自己的代码,因为编写的时候已经做过 debug 调试,完事后觉得足够了,或者期待下次重构再调整之。结果遇到 bug 无法最快时间确定问题,别人接手代码也不知道这个模块的设计意图和使用方法,必须跳进去读代码,也不清楚改了一些内容后会不会影响这个模块功能,又得耗时再次 debug 。在弱类型的语言尤其前端开发中尤为明显。那种决定暂时弃之而不顾的的思想很可怕,因为我们没有听过过勒布朗法则:稍后等于永不。

聊聊测试的几种类型

单元测试

从字面意思理解,写一段代码来测试一个单元。何为单元?其实和编程语言相关,他有可能是一个 function ,一个 module 一个 package 一个类,当然在 JavaScript 中也很有可能只是一个 object 。既然如此,那么测试这样的一个小块基本上就是比较孤立,单独验证这个小块的逻辑,一个 function 的输入输出,一个算法的功能和复杂度等等。接下来举几个企业版前端开发中的实际案例。

我们使用 jest 作为测试框架(断言库)。 jest 会自动搜索所有文件目录下的.spec.js 结尾的文件,然后执行测试。断言库其实还有很多,他们都具备类似 describe , it , expect 些 api 。对于一个没有其他依赖的纯函数,例如 redux 中同步 action 或 reducer 。 我们要测的当然就是输入用例然后对应输出是否符合预期

it('should return showMore action', () => {
    expect(showMore()).toEqual({
        type: ACTION.DEMO_LIST_REMOVE_ITEM,
    });
});

我们注意到这样的一个 function 并没有 I/O 和 UI 上的依赖,他更有利于做单元测试。其中的 it 接受一个 string 参数,描述一个小测试。另一个就是测试方法体函数, it 这种测试不能单独使用,一般都包在一个 describe 方法下成为的方法组。那方法体里写什么呢,其实我也可以写成

if (showMore().type !== ACTION.DEMO_LIST_REMOVE_ITEM)
  throw 'failed'

只要抛出异常那么框架就会认为这条测试跑不过。当然 expect 则 api 更加的漂亮,拥有 toEqual toBe 、 toMapSnap shot 等判断 api 确定两个条件之间的关系. 对于纯函数的测试并不难,难的还是如何把代码写的更可单元测试化,而不要有太多的依赖。

集成测试

事实上很多情况小块代码还是会有函数和 I/O 依赖,比如一些 code 依赖 Ajax 或者 localStorage 或者 IndexedDB ,这样的代码是不能被 united-test 的,于是我们需要 mock 相应依赖的接口拿到上下文测试我们的代码,这样的测试叫集成测试。我们项目中主要依赖了 js-dom 和异步的 action 。下面分别讨论

涉及依赖的函数情况–(异步 action )

事实上很多情况函数还是会有函数和 I/O 依赖,最典型的就是异步 action 等,他的 I/O 可能会依赖 store.getState(),自身又会依赖异步中间键。这类使用原生 js 测试起来是比较困难的。我们思考我们测试目的,即当我们触发了一个 action 后它经历了一个圈异步最终 store.getAction 中这个 action 拿到的数据是否和我们预期一致。既然大家依赖 redux 中 store 的生命周期与 store ,于是我们需要两个工具库 redux-mock-store 和 nock ,于是测试就变成了这样。

import configureMockStore from 'redux-mock-store';
import nock from 'nock';
import thunk from 'redux-thunk';
const mockStore = configureMockStore([thunk]);
// 配置 mock 的 store ,也让他有我们相同的 middleware
describe('get billings actions', () => {
    afterEach(() => nock.cleanAll());// 每执行完一个测试后清空 nock
    it('create get all Billings action', () => {
        const store = mockStore({ 
        // 以我们约定的初始 state 创建 store ,控制 I/O 依赖
            APP: { enterprise: { key : 'codingcorp' } }
        });
        const data = [
            // 接口返回信息
            { ...
            },
        ];
        nock(API_HOST)// 拦截请求返回假定的 response
            .get(`/api/enterprise/codingcorp/billings`)
            .reply(200, { code: 0, data })
        return store.dispatch(actions.getAllBillings())
            .then(() => {
                expect(store.getActions()).toMatchSnapshot();
        });
    });
});
  • 用 nock 来 mock 拦截 http 请求结果,并返回我们给定的 response 。
  • 用 redux-mock-store 来 mock store 的生命周期,需要预先把 middleware 配成和项目一致。
  • desribe 会包含一些生命周期的 api ,比如全部测试开始做啥,单个测试结束做啥这类 api 。这里每执行完一个测试就清空 nock 。
  • 用了 jest 中的 toMatchSnapshot api 判断两个条件一致与否。原先可能要写成expect(store.getActions()).toEqual({data ...});这样,你需要把 equal 里的东西都想具体描写清楚,而 toMatchSnapshot 可在当前目录下生成一个 snapshot 存放这个当前结果,写测试时看一眼结果是预期的就可以 commit 。如果改坏了函数就不匹配 snapshot 了。

涉及依赖的函数情况–( react component )

我们写的很多 component 是 extends component 的 jsx ,测试这类需要一个 mock component 的工具库 Enzyme 。

    it('should add key with never expire', () => {
        ... 
        挂载我们的 dom
        const wrapper = shallow(
            <TwoFactorModal
                verifyKey={verifyKeySpy}
                onVerifySuccess={onVerifySuccessSpy}
            />
        );
        // wrapper 的 setstate 方法
        wrapper.setState({
            name: 'test',
            password: '123',
        });
        const name = 'new name';
        const content = 'new content';
        const expiration = '2016-01-01';
        
        wrapper.find('.name').simulate('change', {}, name);
        wrapper.find('.content').simulate('change', {}, content);
       
        expect(wrapper.find('.permanentCheck').prop('checked')).toBe(true);
        // 此处也可以使用 toMatchSnapshot
        // submit to add
        wrapper.find('.submitBtn').simulate('click', e);

        return promise.then(() => {
            expect(onCheckSuccess).toBeCalledWith({
                name,
                password,
            });
        });
    });

Enzyme 给我们提供了很多 react-dom 的事件操作与数据获取。 这类 component 的测试一般分为

  • Structural Testing 结构测试 主要关心一个界面是否有这些元素 例如我们有一个界面是 Screen Shot 2017-03-26 at 1.25.15 PM.png 结构化测试将包含:

    • 一个 title 包含“登入到 codingcorp.coding.net
    • 一个副标题包含“…”
    • 两个输入框
    • 一个提交按钮 … 比较方便的实现就是利用 jest 的 snapshot 测试方法,先做一个预期生成 snapshot ,之后的版本与预期对比。
  • Interaction Testing 交互测试 比如上述案例触发提交按钮,他应该返回给我用户名和密码,并得到验证结果 这类一般使用 Enzyme 比较方便

样式测试

UI 的样式测试为了测试我们的样式是否复合设计稿预期。同时通过样式测试我们可以感受当我们 code 变化带来的 ui 变化,以及他是否符合预期。

inline style

如果样式是 inline style ,这类测试其实直接使用 jest 的 Snapshot Testing 最方便,一般在组件库中使用。

CSS

这部分其实属于 E2E 测试中的部分,这里提前讲,主要解决的问题是我们写出来的 ui 是否符合设计稿的预期。我们使用 BackstopJS 他的原理是通过对页面的 viewports 和 scenarios 等做配置,利用 web - driver 获取图片,与设计稿或者预期图做 diff ,产生报告。

{
// 需要测试的模块元素定义
  "viewports": [
    {
      "name": "password", //密码框
      "width": 320,
      "height": 480
    },
  ],
  "scenarios": [
    {
      "label": "members",
      "url": "/member/admin",
      "selectors": [ // css 选择器
        ".member-selector"
      ],
      "readyEvent": "gmapResponded",
      "delay": 100,
      "misMatchThreshold" : 1,
      "onBeforeScript": "onBefore.js",
      "onReadyScript": "onReady.js"
    }
  ],
  "paths": {
    "bitmaps_reference": "backstop_data/bitmaps_reference",
    "bitmaps_test": "backstop_data/bitmaps_test",
    "html_report": "backstop_data/html_report",
    "ci_report": "backstop_data/ci_report"
  },
  "casperFlags": [],
  "engine": "slimerjs",
  "report": ["browser"],
  "debug": false
}

最后会得出类似这样的报告 Screen Shot 2017-03-26 at 11.41.38 AM.png

E2E 测试

E2E 测试是在实际生产环境测试整个 app ,通常来说这部分工作会让测试人工做,并在实体环境跑,就像用户实际在操作一样。靠人工做遇到项目逻辑比较复杂,则需要每一个版本都要测很多逻辑,担心提交一个影响了其他部分。其实也有比较好的自动化跑脚本方案能帮助测试,我们使用 selenium-webdriver 工具配合 async await 进行自动化 E2E 测试。

const {prepareDriver, cleanupDriver} = require('../utils/browser-automation')

//...
describe('member', function () {
  let driver
  ...
  before(async () => {
    driver = await prepareDriver()
  })
  after(() => cleanupDriver(driver))

  it('should work', async function () {
  const submitBtn = await driver.findElement(By.css('.submitBtn'))
    await driver.get('http://localhost:4000')
    await retry(async () => {
      const displayElement = await driver.findElement(By.css('.display'))
      const displayText = await displayElement.getText()
      expect(displayText).to.equal('0')
  })
    await submitBtn.click()
})

selenium-webdriver 提供了很多浏览器的操作以及对元素对查找方法,以及元素内容的获取方法,比如这里的 By.css 选择器。 有时候用户端的设备很不一致,需要在不同设备上的匹配,于是我们可以用 selenium-webdriver 搭配 sourcelab 的设备墙进行 sourcelab.png

测试覆盖率与代码变异测试

测试覆盖率表达本次测试有有多少比例的语句,函数分支没有被测到。当然绝对数字作为代码质量依据并没有什么意义,因为它是根据我们写的测试来的。倒是学习为什么有些代码没有被覆盖到,以及为什么有些代码变了测试却没有失败。很有意义。我们在 jestconfig 中配置完目标数据后,每次他会检测我们的测试覆盖率并给我们报告 Screen Shot 2017-03-26 at 12.25.30 PM.png

Function Coverage 函数覆盖

顾名思义,就是指这个函数是否被测试代码调用了。以下面的代码为例 ,对函数 exchange 要做到覆盖,只要一个测试——如 expect(exchange(2, 2)) 就可以了。如果连函数覆盖都达不到,那这个函数是否真的需要。

  let z = 0
  if (x>0 && y>0) {
    z=x
  }
  return z
}

Line Coverage 语句覆盖

还是前面那个 exchange 例子,他检测的是某一行代码是否被测试覆盖了,同样 选择用例 2 , 2 也能覆盖它,但是如果变成 2, -1 就不行了。通常这种情况是由于一些分支语句导致的,因为相应的问题就是“那行代码(以及它所对应的分支)需要吗?

Decision Coverage 决策覆盖

它是指每一个逻辑分支是否被测试覆盖了,有一个 if 的真和假一般就要两组用例,至少测一组 true 一组 false

Condifiton Coverage 条件覆盖

它是指分支中的每个条件是否被测试覆盖了,像前面那个 exchange 例子,要达到全部条件覆盖,测试用例就需要四个,即 x 和 y 四种情况,如果测不到就要思考是否不需要某个分支呢

代码变异测试

说到这里重新提一下 jest 的 toMatchSnapshot 实践,他对期望的表达并不是写一个期望值和实际做匹配,而是生成一个快照让我们之后的每次变异代码和它匹配, jest–watch 的实时测试变动的代码更方便做这个事。 这里所谓的变异是指修改一处代码来改变代码的行为,检查测试是否因为这个代码的变异而失败,如果有失败则说明这个变异被消灭,此时的测试本身行为是符合预期。不然变异存活则测试不到位。 平时用到比较多的变异方法是: 条件边界变异、反向条件变异、数学运算变异、增量运算变异、负值翻转变异等

小结

养成写测试的好习惯能避免很多问题,极大的提升效率,避免重复 debug 。在前端开发中由于语言本身对写法限制比较弱,测试保障非常重要,既让自己对代码有信心也让别人更容易理解你设计的每一个模块用意。在写代码的时候就要从可测试如何测试的角度思考,尽量每一行代码都是有用且符合预期的。

1 回复

感谢分享,mark

回到顶部