聊聊前端开发测试

本文首发我们公司Blog
本文首发于我们公司博客
聊聊前端开发的测试 - Coding 博客最近在做 Coding 企业版 前端开发时花了很多时间写测试,于是和大家分享一些前端开发中的测试概念与方法。 什么是写测试代码 我理解的写测试其实是你写一些代码来验证你所谓的可以交付的代码是你所预期的blog.coding.net
在自己这里也转一发

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

什么是写测试代码

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

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

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

聊聊测试的几种类型

单元测试

从字面意思理解,写一段代码来测试一个单元。何为单元?其实和编程语言相关,他有可能是一个 function,一个 module 一个package 一个类,当然再 js 中也很有可能只是一个 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、toMapSnapshot等判断 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存放这个当前结果,写测试时看一眼结果是预期的就ok可提入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
    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
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
sourcelab.png

后来,由于写测试click还是比较麻烦,尤其对测试而言,我们选用了
testcafe,它能录制在浏览器中每个事件的宏,然后生成测试。这样在对他调整一下,e2e的测试时间就大大降低了。

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

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


Screen Shot 2017-03-26 at 12.25.30 PM.png
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
}```
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,585评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,398评论 25 707
  • TL;DR——什么是好的单元测试? 其实我是个标题党,单元测试根本没有“艺术”可言。 好的测试来自于好的代码,如果...
    ThoughtWorks阅读 3,140评论 1 22
  • pta 多atr 48 持3手 平均成本4862,平均止损线4814,风险度720 4838 止损 4790 48...
    了不起的狐狸巴巴阅读 196评论 0 0
  • 男主为了女主等了她四年,男主在狱中没有告诉女主,只是说去了国外没回来,此间男主做了各种任务,浑浑噩噩地度过了四年,...
    舒一薇阅读 222评论 0 0