阅读 10242

浅谈前端单元测试

      首先声明一点,长期以来,前端开发的单元测试并不是在前端的开发过程中所必须的,也不是每个前端开发工程师所注意和重视的,甚至扩大到软件开发过程中单元测试这一环也不是在章程上有书面规定所要求的。但是随着每个工程的复杂化、代码的高复用性要求和前端代码模块之间的高内聚低耦合的需求,前端工程中的单元测试流程就显得很有其必要。

1.前端单元测试是什么

      首先我们要明确测试是什么:

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

       对于前端开发过程来说,这里的特定目标就是指我们写的代码,而工具就是我们需要用到的测试框架(库)、测试用例等。检测处的结果就是展示测试是否通过或者给出测试报告,这样才能方便问题的排查和后期的修正。

       基于测试“是什么”的说法,为便于刚从事前端开发的同行的进阶理解,那我们就列出单元测试它“不是什么”:

需要访问数据库的测试不是单元测试

需要访问网络的测试不是单元测试

需要访问文件系统的测试不是单元测试

--- 修改代码的艺术

对于单元测试“不是什么”的引用解释,至此点到为止。鉴于篇幅限制,对于引用内容,我想前端开发的同行们看到后会初步有一个属于自己的理解。

2.单元测试的意义以及为什么需要单元测试

2.1   单元测试的意义

      对于现在的前端工程,一个标准完整的项目,测试是非常有必要的。很多时候我们只是完成了项目而忽略了项目测试的部分,测试的意义主要在于下面几点:

  1. TDD(测试驱动开发) 被证明是有效的软件编写原则,它能覆盖更多的功能接口。
  2. 快速反馈你的功能输出,验证你的想法。
  3. 保证代码重构的安全性,没有一成不变的代码,测试用例能给你多变的代码结构一个定心丸。
  4. 易于测试的代码,说明是一个好的设计。做单元测试之前,肯定要实例化一个东西,假如这个东西有很多依赖的话,这个测试构7. 造过程将会非常耗时,会影响你的测试效率,怎么办呢?要依赖分离,一个类尽量保证功能单一,比如视图与功能分离,这样的话,你的代码也便于维护和理解。

2.2   为什么需要单元测试

  1. 首先是一个前端单元测试的根本性原由:JavaScript 是动态语言,缺少类型检查,编译期间无法定位到错误; JavaScript 宿主的兼容性问题。比如 DOM 操作在不同浏览器上的表现。
  2. 正确性:测试可以验证代码的正确性,在上线前做到心里有底。
  3. 自动化:当然手工也可以测试,通过console可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行。
  4. 解释性:测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些API。其他开发人员如果要使用这些API,那阅读测试用例是一种很好地途径,有时比文档说明更清晰。
  5. 驱动开发,指导设计:代码被测试的前提是代码本身的可测试性,那么要保证代码的可测试性,就需要在开发中注意API的设计,TDD将测试前移就是起到这么一个作用。
  6. 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构。

3.如何写单元测试用例

3.1 原则

  • 测试代码时,只考虑测试,不考虑内部实现
  • 数据尽量模拟现实,越靠近现实越好
  • 充分考虑数据的边界条件
  • 对重点、复杂、核心代码,重点测试
  • 利用AOP(beforeEach、afterEach),减少测试代码数量,避免无用功能
  • 测试、功能开发相结合,有利于设计和代码重构

3.2 两个常用的单元测试方法论

   在单元测试中,常用的方法论有两个:TDD(测试驱动开发)&BDD(行为驱动开发)

   对于之前没听说过前端测试这两个模式的同行可以在此了解一下,篇幅限制此处不再敖述。

3.3 相信你看完之后也有一个自己对TDD和BDD的个人观点,在此我先谈谈我对TDD和BDD的 理解:

TDD(Test-driven development)

其基本思路是通过测试来推动整个开发的进行。

  • 单元测试的首要目的不是为了能够编写出大覆盖率的全部通过的测试代码,而是需要从使用者(调用者)的角度出发,尝试函数逻辑的各种可能性,进而辅助性增强代码质量

  • 测试是手段而不是目的。测试的主要目的不是证明代码正确,而是帮助发现错误,包括低级的错误

  • 测试要快。快速运行、快速编写

  • 测试代码保持简洁

  • 不会忽略失败的测试。一旦团队开始接受1个测试的构建失败,那么他们渐渐地适应2、3、4或者更多的失败。在这种情况下,测试集就不再起作用

需要注意的是

  • 一定不能误解了TDD的核心目的!

  • 测试不是为了覆盖率和正确率

  • 而是作为实例,告诉开发人员要编写什么代码

  • 红灯(代码还不完善,测试挂)-> 绿灯(编写代码,测试通过)-> 重构(优化代码并保证测试通过)

TDD的过程是

  1. 需求分析,思考实现。考虑如何“使用”产品代码,是一个实例方法还是一个类方法,是从构造函数传参还是从方法调用传参,方法的命名,返回值等。这时其实就是在做设计,而且设计以代码来体现。此时测试为红

  2. 实现代码让测试为”绿灯“

  3. 重构,然后重复测试

  4. 最终符合所有要求即:

    • 每个概念都被清晰的表达

    • 代码中无自我重复

    • 没有多余的东西

    • 通过测试

BDD(Behavior-driven development):

行为驱动开发(BDD),重点是通过与利益相关者(简单说就是客户)的讨论,取得对预期的软件行为的认识,其重点在于沟通

BDD过程是:

  1. 从业务的角度定义具体的,以及可衡量的目标

  2. 找到一种可以达到设定目标的、对业务最重要的那些功能的方法

  3. 然后像故事一样描述出一个个具体可执行的行为。其描述方法基于一些通用词汇,这些词汇具有准确无误的表达能力和一致的含义。例如,expect, should, assert

  4. 寻找合适语言及方法,对行为进行实现

  5. 测试人员检验产品运行结果是否符合预期行为。最大程度的交付出符合用户期望的产品,避免表达不一致带来的问题

4. Mocha/Karma+Travis.CI的前端测试工作流

      以上内容从什么是单元测试谈到单元测试的方法论。那么怎样用常用框架进行单元测试?单元测试的工具环境是什么?单元测试的实际示例是怎样的?

      首先应该简单介绍一下Mocha、Karma和Travis.CI

Mocha:mocha 是一个功能丰富的前端测试框架。所谓"测试框架",就是运行测试的工具。通过它,可以为JavaScript应用添加测试,从而保证代码的质量。mocha 既可以基于 Node.js 环境运行 也可以在浏览器环境运行。欲了解更多可去官方网站进行学习。其官方介绍为:

Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun. Mocha tests run serially, allowing for flexible and accurate reporting, while mapping uncaught exceptions to the correct test cases. Hosted on GitHub.

Karma:一个基于Node.js的JavaScript测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流Web浏览器,也可集成到CI(Continuous integration)工具,也可和其他代码编辑器一起使用。这个测试工具的一个强大特性就是,它可以监控文件的变化,然后自行执行,通过console.log显示测试结果。Karma的一个强大特性就是,它可以监控一套文件的变换,并立即开始测试已保存的文件,用户无需离开文本编辑器。测试结果通常显示在命令行中,而非代码编辑器。这也就让 Karma 基本可以和任何 JS 编辑器一起使用。

Travis.CI: 提供的是持续集成服务(Continuous Integration,简称 CI)。它绑定 Github 上面的项目,只要有新的代码,就会自动抓取。然后,提供一个运行环境,执行测试,完成构建,还能部署到服务器。

持续集成指的是只要代码有变更,就自动运行构建和测试,反馈运行结果。确保符合预期以后,再将新代码"集成"到主干。

持续集成的好处在于,每次代码的小幅变更,就能看到运行结果,从而不断累积小的变更,而不是在开发周期结束时,一下子合并一大块代码。

对于Travis.CI,建议移步到阮大大廖大大的个人网站上学习,两位老师讲的要比我在这儿写的更清晰。

断言库

     基本工具框架介绍完毕后,相信稍微了解点测试的同行都知道,做单元测试是需要写测试脚本的,那么测试脚本就需要用到断言库。”断言“,个人理解即为”用彼代码断定测试此代码的正确性,检验并暴露此代码的错误。“那么对于前端单元测试来说,有以下常用断言库:

看一段代码示例:

expect(add(1, 1)).to.be.equal(2);

这是一句断言代码。

所谓"断言",就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误。上面这句断言的意思是,调用 add(1, 1),结果应该等于 2。所有的测试用例(it 块)都应该含有一句或多句的断言。它是编写测试用例的关键。断言功能由断言库来实现,Mocha 本身不带断言库,所以必须先引入断言库。

引入断言库代码示例:

var expect = require('chai').expect;

断言库有很多种,Mocha 并不限制使用哪一种,它允许你使用你想要的任何断言库。上面代码引入的断言库是 chai,并且指定使用它的 expect 断言风格。下面这些常见的断言库:

此处主要介绍一下node assert中常用的API

  • assert(value[, message])
  • assert.ok(value[, message])
  • assert.equal(actual, expect[, message])
  • assert.notEqual(actual, expected[, message])
  • assert.strictEqual(actual, expect[, message])
  • assert.notStrictEqual(actial, expected[, message])
  • assert.deepEqual(actual, expect[, message])
  • assert.notDeepEqual(actual, expected[, message])
  • assert.deepStrictEqual(actual, expect[, message])
  • assert.notDeepStrictEqual(actual, expected[, message])
  • assert.throws(block[, error][, message])
  • assert.doesNotThrow(block[, error][, message])

assert(value[, message])

断言 value 的值是否为true,这里的等于判断使用的是 == 而不是 ===。message 是断言描述,为可选参数。

const assert = require('assert');
assert(true);
复制代码

assert.ok(value[, message])

使用方法同 assert(value[, message])

assert.equal(actual, expect[, message])

预期 actual 与 expect值相等。equal用于比较的 actual 和 expect 是基础类型(string, number, boolearn, null, undefined)的数据。其中的比较使用的是 == 而不是 ===。

it('assert.equal', () => {
  assert.equal(null, false, 'null compare with false');  // 报错
  assert.equal(null, true, 'null compare with true');  // 报错
  assert.equal(undefined, false, 'undefined compare with false'); // 报错
  assert.equal(undefined, true, 'undefined compare with true'); // 报错
  assert.equal('', false, '"" compare with false');  // 正常
})
复制代码

notEqual(actual, expected[, message])

用法同 assert.equal(actual, expect[, message]) 只是对预期结果取反(即不等于)。

assert.strictEqual(actual, expect[, message])

用法同 assert.equal(actual, expect[, message]) 但是内部比较是使用的是 === 而不是 ==。

assert.notStrictEqual(actial, expected[, message])

用法同 assert.strictEqual(actual, expect[, message]) 只是对预期结果取反(即不严格等于)。

it('assert.strictEqual', () => {
  assert.strictEqual('', false); // 报错
})
复制代码

assert.deepEqual(actual, expect[, message])

deepEqual 方法用于比较两个对象。比较的过程是比较两个对象的 key 和 value 值是否相同, 比较时用的是 == 而不是 ===。

it('assert.deepEqual', () => {
  const a = { v: 'value' };
  const b = { v: 'value' };
  assert.deepEqual(a, b);
})
复制代码

assert.notDeepEqual(actual, expected[, message])

用法同 assert.deepEqual(actual, expect[, message]) 只是对预期结果取反(即不严格深等于)。

assert.deepStrictEqual(actual, expect[, message])

用法同 assert.deepEqual(actual, expect[, message]) 但是内部比较是使用的是 === 而不是 ==。

assert.notDeepStrictEqual(actual, expected[, message])

用法同 assert.deepStrictEqual(actual, expect[, message]) 只是对结果取反(即不严格深等于)。

assert.throws(block[, error][, message])

错误断言与捕获, 断言指定代码块运行一定会报错或抛出错误。若代码运行未出现错误则会断言失败,断言异常。

it('throws', () => {
  var fun = function() {
    xxx
  };
  assert.throws(fun, 'fun error');
})
复制代码

assert.doesNotThrow(block[, error][, message])

错误断言与捕获, 用法同 throws 类似,只是和 throws 预期结果相反。断言指定代码块运行一定不会报错或抛出错误。若代码运行出现错误则会断言失败,断言异常。

it('throws', () => {
  var fun = function() {
    xxx
  };
  assert.doesNotThrow(fun, 'fun error');
})复制代码


相应的工具介绍之后,针对Mocha、Karma以及Travis.CI的用法谈点个人在操作时的实践经验。

Mocha

  • 安装mocha
npm install mocha -g
复制代码

当然也可以在不在全局安装,只安局部安装在项目中

npm install mocha --save
复制代码
  • 创建一个测试文件 test.js
var assert = require('assert')

describe('Array', function() {
  describe('#indexOf()', function() {
    it('should return -1 when the value is not present', function() {
      assert.equal(-1, [1, 2, 3].indexOf(-1))
    })
  })
})
复制代码

这段文件和简单就是测试 Array 的一个 indexOf() 方法。这里我是用的断言库是 Node 所提供的 Assert 模块里的API。这里断言 -1 等于 数组 [1, 2, 3] 执行 indexOf(-1)后返回的值,如果测试通过则不会报错,如果有误就会报出错误。

下面我们使用全局安装的 mocha 来运行一下这个文件 mocha test.js
下面是返回结果


基础测试用例实例

const assert = require('assert');

describe('测试套件描述', function() {
  it('测试用例描述: 1 + 2 = 3', function() {
    // 测试代码
    const result = 1 + 2;
    // 测试断言
    assert.equal(result, 3);
  });
});
复制代码

Mocha 测试用例主要包含下面几部分:

  1. describe 定义的测试套件(test suite)
  2. it 定义的测试用例(test case)
  3. 测试代码
  4. 断言部分

说明:每个测试文件中可以有多个测试套件和测试用例。mocha不仅可以在node环境运行, 也可以在浏览器环境运行;在node中运行也可以通过npm i mocha -g全局安装mocha然后以命令行的方式运行测试用例也是可行的。

这里略微详细介绍下测试脚本写法

Mocha 的作用是运行测试脚本,首先必须学会写测试脚本。所谓"测试脚本",就是用来测试源码的脚本。下面是一个加法模块 add.js 的代码。

// add.js
function add(x, y) {
  return x + y;
}

module.exports = add;
复制代码

要测试这个加法模块是否正确,就要写测试脚本。通常,测试脚本与所要测试的源码脚本同名,但是后缀名为.test.js(表示测试)或者.spec.js(表示规格)。比如,add.js 的测试脚本名字就是 add.test.js。

// add.test.js
var add = require('./add.js');
var expect = require('chai').expect;

describe('加法函数的测试', function() {
  it('1 加 1 应该等于 2', function() {
    expect(add(1, 1)).to.be.equal(2);
  });
});
复制代码

上面这段代码,就是测试脚本,它可以独立执行。测试脚本里面应该包括一个或多个 describe 块,每个 describe 块应该包括一个或多个 it 块。

describe 块称为"测试套件"(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称("加法函数的测试"),第二个参数是一个实际执行的函数。

it 块称为"测试用例"(test case),表示一个单独的测试,是测试的最小单位。它也是一个函数,第一个参数是测试用例的名称("1 加 1 应该等于 2"),第二个参数是一个实际执行的函数。


expect 断言的优点是很接近自然语言,下面是一些例子。

// 相等或不相等
expect(4 + 5).to.be.equal(9);
expect(4 + 5).to.be.not.equal(10);
expect(foo).to.be.deep.equal({ bar: 'baz' });

// 布尔值为true
expect('everthing').to.be.ok;
expect(false).to.not.be.ok;

// typeof
expect('test').to.be.a('string');
expect({ foo: 'bar' }).to.be.an('object');
expect(foo).to.be.an.instanceof(Foo);

// include
expect([1, 2, 3]).to.include(2);
expect('foobar').to.contain('foo');
expect({ foo: 'bar', hello: 'universe' }).to.include.keys('foo');

// empty
expect([]).to.be.empty;
expect('').to.be.empty;
expect({}).to.be.empty;

// match
expect('foobar').to.match(/^foo/);
复制代码

基本上,expect 断言的写法都是一样的。头部是 expect 方法,尾部是断言方法,比如 equal、a/an、ok、match 等。两者之间使用 to 或 to.be 连接。如果 expect 断言不成立,就会抛出一个错误。事实上,只要不抛出错误,测试用例就算通过。

it('1 加 1 应该等于 2', function() {});
复制代码

上面的这个测试用例,内部没有任何代码,由于没有抛出了错误,所以还是会通过。


Karma

基于 karma 测试常用的一些模块

模块安装

# 基础测试库
npm install karma-cli -g
npm install karma mocha karma-mocha --save-dev

# 断言库
npm install should --save-dev
npm install karma-chai --save-dev

# 浏览器相关
npm install karma-firefox-launcher --save-dev
npm install karma-chrome-launcher --save-dev
复制代码

配置

这里的配置主要关注的是karma.conf.js的相关配置。如果要使用 karma 和 mocha 最好通过npm install karma-cli -g全局安装karma-cli具体配置配置说明

需要注意的两个字段:

  • singleRun: 如果值为 true, 则在浏览器运行完测试后会自动退出关闭浏览器窗口。singleRun的值我们可以更具运行环境来动态赋值, 可以启动命令中添加NODE_ENV变量。
  • browsers: 浏览器配置(可以配置多个浏览器); 如果浏览器无法启动需要进行相关浏览器的配置。设置自启动浏览器时候如果浏览器启动失败可能需要设置为--no-sandbox模式。
{
  "browsers": ["Chrome", "ChromeHeadless", "ChromeHeadlessNoSandbox"],
  "customLaunchers": {
    "ChromeHeadlessNoSandbox": {
      "base": "ChromeHeadless",
      "flags": ["--no-sandbox"]
    }
  }
}
复制代码

或者

{
  "browsers": ["Chrome_travis_ci"],
  "customLaunchers": {
    "Chrome_travis_ci": {
      "base": "Chrome",
      "flags": ["--no-sandbox"]
    }
  }
}复制代码


Github项目接入Travis.CI进行集成自动化测试的步骤

  • 在github创建并完成一个可以待测试的项目。这里的完成是指需要完成基本的项目功能,和测试用例代码。
  • 配置travis-ci能识别读取的配置文件,这样travis-ci接入的时候才能够知道测试时的一些配置。
  • github 和 travis-ci 是个站点,换句话说就是两个东西如果能打通呢。需要用户登录 travis-ci 并授权访问到你的 github 项目并进行相关的项目设置。
  • 接入完成后就可以根据自己的需要来运行写好的测试代码,也可以设置定期任务去跑测试。

  • 项目创建、完善项目功能和测试代码。

    • 项目需求: 实现一个求和方法
    • 测试: 通过 mocha 来测试完成的求和方法。

    下面是项目结构,项目创建完成后通过 npm i mocha -D 安装 mocha 模块。然后在本地运行 npm test 看是否能够测试通过。如果能够测试通过则说明我们的可以继续下一步了。


    创建 travis-ci 测试配置文件

    创建 travis-ci 配置文件 .travis.yml, 文件内容。更多关于配置文件的说明在travis官网可查询

    language: node_js
    node_js:
      - "node"
      - "8.9.4"
    复制代码

    至此基本完成了项目开发和测试代码编写的过程,下一步就可以接入 travis-ci 测试了。

    接入 travis-ci

    通过GitHub登录 travis-ci 的官网 www.travis-ci.org/


    找到GitHub上刚才创建的需要测试的项目,并开启测试


    查看测试过程,及时发现问题。


    查看测试状态是否通过测试,如果未通过及时排查问题反复修改;如果通过可以在项目文档中添加一个测试通过的标识。

    总结

    年初的时候我曾去面试一个公司,公司是一个创业公司,老板是前百度首席架构师林仕鼎先生,公司名字叫爱云校,主要业务是为基础教育提供数据服务,有自己的平台和产品。当天面试官问我的面试问题中最令我印象深刻的就是:以前你做前端的时候的单元测试所用的框架和工作流是什么?当时说实话我很懵逼,懵逼到我甚至不知道在前端领域什么是单元测试?要测试什么?用的啥工具?一时语塞,我说以前没有用过,也没有做过前端测试。当时面试官老哥明显脸上有些失望,但是最终的结果是公司录用了我,我说出这个经历的原因,其实想传达的意思是:现在的前端开发早已不是早年的切图、特效实现和视觉表现。现在的前端开发工程师更多的是你作为一个在整个项目产品开发团队中理论上(其实也是事实上)离用户最近的一个岗位,应不仅仅限于实现纯传统前端的功能,更需要基于明白客户需求这个基础上统筹好前端和后端的良好耦合,以及前端功能的准确无误。根据自己的面试求职经历,今天在这里谈到的前端单元测试的内容,个人认为在某些公司项目中,可能并不是必须的,但作为发展最快的IT领域之一,前端工程师掌握前端单元测试在未来时间只能是要求越来越硬性,而不是一直停留在可有可无了解范畴,因为这跟前端开发的发展趋势息息相关。

    但是同时,在我个人观点范畴内,至少目前我还是坚持开发为主测试为辅的流程,对于像TDD这种单元测试指导开发流程,目前并不推崇。个人认为,这是一个很有创新性的方法论,也并不是现在完全不可行,个人认为只是可行的范畴还不够宽,可行的条件要求还很严苛。所以相对于TDD,测试主导开发,对于目前准备进阶的前端开发者,个人更建议,了解某种以后会使用的新趋势和技术是有必要的,但作为技术人应该在学习新的、前卫的技术的同时不可迷失自我一味追求新技术,更重要的是要磨练当下的主流技能。相比于未来的单元测试主导开发流程,倒不如在目前这个时间节点精进基础开发流程,比如让自己的JS代码更专注于模块化和功能化的实现,这样的同时也会让单元测试更有效率,真正发挥目前单元测试对前端工程化的作用。


    关注下面的标签,发现更多相似文章
    评论