首先声明一点,长期以来,前端开发的单元测试并不是在前端的开发过程中所必须的,也不是每个前端开发工程师所注意和重视的,甚至扩大到软件开发过程中单元测试这一环也不是在章程上有书面规定所要求的。但是随着每个工程的复杂化、代码的高复用性要求和前端代码模块之间的高内聚低耦合的需求,前端工程中的单元测试流程就显得很有其必要。
1.前端单元测试是什么
首先我们要明确测试是什么:
为检测特定的目标是否符合标准而采用专用的工具或者方法进行验证,并最终得出特定的结果
对于前端开发过程来说,这里的特定目标就是指我们写的代码,而工具就是我们需要用到的测试框架(库)、测试用例等。检测处的结果就是展示测试是否通过或者给出测试报告,这样才能方便问题的排查和后期的修正。
基于测试“是什么”的说法,为便于刚从事前端开发的同行的进阶理解,那我们就列出单元测试它“不是什么”:
需要访问数据库的测试不是单元测试
需要访问网络的测试不是单元测试
需要访问文件系统的测试不是单元测试
需要访问文件系统的测试不是单元测试
— 修改代码的艺术
对于单元测试“不是什么”的引用解释,至此点到为止。鉴于篇幅限制,对于引用内容,我想前端开发的同行们看到后会初步有一个属于自己的理解。
2.单元测试的意义以及为什么需要单元测试
2.1 单元测试的意义
对于现在的前端工程,一个标准完整的项目,测试是非常有必要的。很多时候我们只是完成了项目而忽略了项目测试的部分,测试的意义主要在于下面几点:
- TDD(测试驱动开发) 被证明是有效的软件编写原则,它能覆盖更多的功能接口。
- 快速反馈你的功能输出,验证你的想法。
- 保证代码重构的安全性,没有一成不变的代码,测试用例能给你多变的代码结构一个定心丸。
- 易于测试的代码,说明是一个好的设计。做单元测试之前,肯定要实例化一个东西,假如这个东西有很多依赖的话,这个测试构7. 造过程将会非常耗时,会影响你的测试效率,怎么办呢?要依赖分离,一个类尽量保证功能单一,比如视图与功能分离,这样的话,你的代码也便于维护和理解。
2.2 为什么需要单元测试
- 首先是一个前端单元测试的根本性原由:JavaScript 是动态语言,缺少类型检查,编译期间无法定位到错误; JavaScript 宿主的兼容性问题。比如 DOM 操作在不同浏览器上的表现。
- 正确性:测试可以验证代码的正确性,在上线前做到心里有底。
- 自动化:当然手工也可以测试,通过console可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行。
- 解释性:测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些API。其他开发人员如果要使用这些API,那阅读测试用例是一种很好地途径,有时比文档说明更清晰。
- 驱动开发,指导设计:代码被测试的前提是代码本身的可测试性,那么要保证代码的可测试性,就需要在开发中注意API的设计,TDD将测试前移就是起到这么一个作用。
- 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构。
3.如何写单元测试用例
3.1 原则
- 测试代码时,只考虑测试,不考虑内部实现
- 数据尽量模拟现实,越靠近现实越好
- 充分考虑数据的边界条件
- 对重点、复杂、核心代码,重点测试
- 利用AOP(beforeEach、afterEach),减少测试代码数量,避免无用功能
- 测试、功能开发相结合,有利于设计和代码重构
3.2 两个常用的单元测试方法论
在单元测试中,常用的方法论有两个:TDD(测试驱动开发)&BDD(行为驱动开发)。
TDD(Test-driven development):
其基本思路是通过测试来推动整个开发的进行。
- 单元测试的首要目的不是为了能够编写出大覆盖率的全部通过的测试代码,而是需要从使用者(调用者)的角度出发,尝试函数逻辑的各种可能性,进而辅助性增强代码质量
- 测试是手段而不是目的。测试的主要目的不是证明代码正确,而是帮助发现错误,包括低级的错误
- 测试要快。快速运行、快速编写
- 测试代码保持简洁
- 不会忽略失败的测试。一旦团队开始接受1个测试的构建失败,那么他们渐渐地适应2、3、4或者更多的失败。在这种情况下,测试集就不再起作用
需要注意的是: - 一定不能误解了TDD的核心目的!
- 测试不是为了覆盖率和正确率,而是作为实例,告诉开发人员要编写什么代码
- 红灯(代码还不完善,测试挂)-> 绿灯(编写代码,测试通过)-> 重构(优化代码并保证测试通过)
TDD的过程是:
- 需求分析,思考实现。考虑如何“使用”产品代码,是一个实例方法还是一个类方法,是从构造函数传参还是从方法调用传参,方法的命名,返回值等。这时其实就是在做设计,而且设计以代码来体现。此时测试为红
- 实现代码让测试为”绿灯“
- 重构,然后重复测试
- 最终符合所有要求即:
- 每个概念都被清晰的表达
- 代码中无自我重复
- 没有多余的东西
- 通过测试
BDD(Behavior-driven development):
行为驱动开发(BDD),重点是通过与利益相关者(简单说就是客户)的讨论,取得对预期的软件行为的认识,其重点在于沟通
BDD过程是:
- 从业务的角度定义具体的,以及可衡量的目标
- 找到一种可以达到设定目标的、对业务最重要的那些功能的方法
- 然后像故事一样描述出一个个具体可执行的行为。其描述方法基于一些通用词汇,这些词汇具有准确无误的表达能力和一致的含义。例如,expect, should, assert
- 寻找合适语言及方法,对行为进行实现
- 测试人员检验产品运行结果是否符合预期行为。最大程度的交付出符合用户期望的产品,避免表达不一致带来的问题
Mocha/Karma+Travis.CI的前端测试工作流
以上内容从什么是单元测试谈到单元测试的方法论。那么怎样用常用框架进行单元测试?单元测试的工具环境是什么?单元测试的实际示例是怎样的?
首先应该简单介绍一下Mocha、Karma和Travis.CI
Mocha:mocha 是一个功能丰富的前端测试框架。所谓”测试框架”,就是运行测试的工具。通过它,可以为JavaScript应用添加测试,从而保证代码的质量。mocha 既可以基于 Node.js 环境运行 也可以在浏览器环境运行。欲了解更多可去官方网站进行学习。
Karma:一个基于Node.js的JavaScript测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流Web浏览器,也可集成到CI(Continuous integration)工具,也可和其他代码编辑器一起使用。这个测试工具的一个强大特性就是,它可以监控文件的变化,然后自行执行,通过console.log显示测试结果。Karma的一个强大特性就是,它可以监控一套文件的变换,并立即开始测试已保存的文件,用户无需离开文本编辑器。测试结果通常显示在命令行中,而非代码编辑器。这也就让 Karma 基本可以和任何 JS 编辑器一起使用。
Travis.CI: 提供的是持续集成服务(Continuous Integration,简称 CI)。它绑定 Github 上面的项目,只要有新的代码,就会自动抓取。然后,提供一个运行环境,执行测试,完成构建,还能部署到服务器。
持续集成指的是只要代码有变更,就自动运行构建和测试,反馈运行结果。确保符合预期以后,再将新代码”集成”到主干。
持续集成的好处在于,每次代码的小幅变更,就能看到运行结果,从而不断累积小的变更,而不是在开发周期结束时,一下子合并一大块代码。
对于Travis.CI,建议移步到阮大大和廖大大的个人网站上学习,两位老师讲的要比我在这儿写的更清晰。
Jest 是用来创建、执行和构建测试用例的一个 JavaScript 测试库。你可以在任何项目中以 npm 包的形式,安装并使用它。Jest 是当前最受欢迎的测试执行器,并且是在创建 React App 时的默认选项。
断言库
基本工具框架介绍完毕后,相信稍微了解点测试的同行都知道,做单元测试是需要写测试脚本的,那么测试脚本就需要用到断言库。”断言“,个人理解即为”用彼代码断定测试此代码的正确性,检验并暴露此代码的错误。“那么对于前端单元测试来说,有以下常用断言库:
看一段代码示例:
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');
})
下面我们以jest为例子做一个单元测试简单demo
创建项目
每个 JavaScript 项目都需要一个 NPM 环境(确保系统中安装了 Node)。下面,我们创建一个新的文件夹,并且初始化项目。
mkdir jest-demo && cd $_
npm init -y
npm i jest --save-dev
然后我们配置下 NPM 脚本,为了能够在命令行执行我们的测试用例。打开 package.json,将执行 Jest 的命令命名为“test”:
"scripts": {
"test": "jest"
},
接下来在 tests 文件夹中创建一个文件 filterByTerm.spec.js。你可能存在疑问,为什么文件名中包含一个“.spec”?这其实是从 Ruby 借鉴而来的一种约定,用于将文件标记为特定功能的规范。
测试结构 & 第一个失败测试
好了,现在常见第一个 Jest 测试用例。打开文件 filterByTerm.spec.js,创建一个测试块
describe("Filter function", () => {
// test stuff
});
现在可以准备写测试了。在 Jest 测试中,我们把测试函数包装在 expect 里面,并且搭配 匹配器 (用来检查输入的 Jest 函数)一起使用,来完成测试。下面列出了完整测试代码:
describe("Filter function", () => {
test("it should filter by a search term (link)", () => {
const input = [
{ id: 1, url: "https://www.url1.dev" },
{ id: 2, url: "https://www.url2.dev" },
{ id: 3, url: "https://www.link3.dev" }
];
const output = [{ id: 3, url: "https://www.link3.dev" }];
expect(filterByTerm(input, "link")).toEqual(output);
});
});
代码覆盖率
什么是代码覆盖率?在谈论这个之前,我们先对代码做下调整。在项目根目录下创建一个名为 src 的文件夹,再在里面创建一个名为 filterByTerm.js 的文件。我们在这里 export 出这个函数:
mkdir src && cd _$
touch filterByTerm.js
下面是文件 filterByTerm.js 的内容:
function filterByTerm(inputArr, searchTerm) {
if (!searchTerm) throw Error("searchTerm cannot be empty");
const regex = new RegExp(searchTerm, "i");
return inputArr.filter(function(arrayElement) {
return arrayElement.url.match(regex);
});
}
module.exports = filterByTerm;
现在假设我是你们公司新来的同事。我对测试一无所知,在不清楚我们开发环境的情况下,我在这个函数里加了一个 if 语句:
function filterByTerm(inputArr, searchTerm) {
if (!searchTerm) throw Error("searchTerm cannot be empty");
if (!inputArr.length) throw Error("inputArr cannot be empty"); // new line
const regex = new RegExp(searchTerm, "i");
return inputArr.filter(function(arrayElement) {
return arrayElement.url.match(regex);
});
}
module.exports = filterByTerm;
我们在 filterByTerm 里加了一行新代码,但没有被测试。除非我告诉你“这里有个新语句需要测试”,你是不会知道要测试什么的。几乎不可能知道我们的代码会走的所有路径,因此需要一种工具来帮助我们发现这些盲点。
这种工具称为代码覆盖率,它是我们工具箱里的一个强大工具。Jest 内置了代码覆盖率工具,你可以使用两种方式激活它:
- 在命令行中通过 “–coverage” flag 指定
- 在 package.json 中手动配置
如果想要在每次测试的时候,都要做代码覆盖率检查,可以在 package.json 对 jest 做出如下配置:
"scripts": {
"test": "jest"
},
"jest": {
"collectCoverage": true,
"coverageReporters": ["html"]
},
每次运行 npm test 时,您都可以在项目中看见一个名为 coverage 的文件夹:getting-started-with-jest/coverage/。在此目录中,你会看见一堆文件,而 /coverage/index.html 就是这些文件的索引页,是对代码覆盖率的总结说明。
得到如下的结果:
PASS __tests__/filterByTerm.spec.js
Filter function
✓ it should filter by a search term (link) (3ms)
✓ it should filter by a search term (uRl) (1ms)
✓ it should throw when searchTerm is empty string (2ms)
-----------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------------|----------|----------|----------|----------|-------------------|
All files | 87.5 | 75 | 100 | 100 | |
filterByTerm.js | 87.5 | 75 | 100 | 100 | 3 |
-----------------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
这是对我们函数测试覆盖率的一个很好的总结。我们看见 第 3 行没有覆盖。现在来测试我新添加的 if 语句,来达到 100% 的代码覆盖率。
测试是一个大且迷人的话题。现在有许多种类型的测试和许多可以选择的测试库。在本篇介绍前端测试的教程里,我们学习了如何配置 Jest 覆盖率报告,如何组织和编写一个简单的单元测试,以及如何测试 JavaScript 代码。
没了。
参考: