前端 · 单元测试 · 初窥
大厂技术 坚持周更 精选好文
从软件测试开始
首先看看百科的定义:
维基百科
在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。
百度百科
软件测试是使用人工或自动的手段来运行或测定某个软件系统的过程,其目的在于检验它是否满足规定的需求或弄清预期结果与实际结果之间的差别。
关键词:发现(错误)、衡量(质量)、评估(差别)
测试方法分类
按照不同的角度,可以划分出多种分类方式,下面列举了常见的几种:
按照程序执行状态:静态测试、动态测试 按照算法实现和系统结构:黑盒测试、白盒测试、灰盒测试 按照程序执行方式:人工测试、自动化测试 按照验收阶段:单元测试、集成测试、系统测试、验收测试
实际的测试过程或者测试工具会交叉融合多种分类方式,比如某个测试工具通过自动化的黑盒测试动态服务于系统单元。
测试的通用原理
实现一个测试工具,最基本也是最核心的要求
模拟输入,运行测试,对比输出。
前端领域中的测试
常见的测试方式
此处的测试方式并非术语
单元测试:对最小可测试单元进行检查和验证,比如一个function、class或者组件等,由开发者组织进行,是使用最多也是最容易组织的测试方式。 集成测试:根据业务需求将多个单元整合到一起进行测试,通常与单元测试界限不会很清晰,一般由开发者组织进行。 端到端测试:也叫做功能测试、冒烟测试,即系统测试,从用户角度出发进行的测试,通常由专业的测试人员进行,是不可或缺的测试,生产上线的保障。
实际开发中,进行测试的梯次大概是:function => class * => component => module => system。
class不一定存在,或者可能与function或component并列
常用的测试框架
从测试角度来看,单元测试以上级别的框架其实都是为了实现更多更高级的功能而对单测框架进行的改造和扩展
单元测试
单元测试作为测试框架的基石,有着举足轻重的地位
对比目前比较流行的几个框架,Jest、Mocha、Ava、Jasmine、Tape。
下载量和⭐️数量 ava vs jasmine vs jest vs mocha vs tape | npm trends
从历史来看,Jest、Mocha和Ava拥有很高的点赞量,从最近下载来看,Ava已经落下神坛。当前的主流应该是Jest和Mocha。(Jest绑定到了create-react-app上面,下载量有水分)。
功能对比
除了断言外,其他功能都不是测试的基本必须功能
框架名称 | 断言 | 快照 | 覆盖率 | 仿真 | 文档 | 开源 |
---|---|---|---|---|---|---|
Jest | 是 | 是 | 是 | 是 | 中文 | MIT licensed |
Mocha | 否 | 否 | 否 | 否 | 英文 | MIT licensed |
Jasmine | 是 | 是 | 否 | 是 | 英文 | MIT licensed |
Ava | 是 | 是 | 是 | 否 | 中文 | MIT licensed |
Tape | 是 | 否 | 否 | 否 | 英文 | MIT licensed |
各自特点()
Jest:功能齐全,开箱即用;优先运行之前失败的用例优化速度等;对扩展不是很友好等。 Mocha:灵活使用,对扩展友好;可以在浏览器环境运行测试;需要辅助库配合使用等。 Jasmine:经典耐用,功能较全;配置复杂,过程繁琐;Jest的基础框架等。 Ava:短小精悍,并行运行;鲜为人知,下载量小等。 Tape:极简风格,适合学习;只有几个文件(核心代码有700多行) ; 如何选择
可以根据需要按照上表的功能支持选择合适的工具,但是目前的主流趋势有两种:
Jest一步到位,有必要可以配合其他的工具,一般来说,没有必要,结合其他工具库有成本; Mocha+各种扩展,比如chai用于断言,sinon用于模拟,Istanbul用于覆盖率等;
端到端测试
对web前端来说,主要的测试包括表单、动画、页面跳转、dom渲染、Ajax等是否符合预期,从这方面讲,已经包括了集成测试和单元测试。
同样对比了当前的一些流行框架,同时也和Mocha的下载量对比了下。
下载量
和Mocha相比,完全不在一个数量级,说明单测的使用规模还是远超过功能测试的,原因可能是是多方面的:单测的使用场景相对更多;功能测试由专业的测试团队完成,可能有更加专业的工具;项目的复杂情况导致功能测试不能通过或者完全通过自动化测试完成等等。
组件测试
为了用于Web端应用而设计出的测试框架,就是在单测和集成测试的基础上增加了一些适用于浏览器、前端的功能。
比较出名的是Testing Library家族,提供了诸多流行框架的组件测试能力。但是很难统一起来组织,所以单独为各个框架提供了工具。
其他辅助工具
覆盖率测试工具 IstanbulJS等。 断言库 Chai、Unexpected等。 模拟库 SinonJS、TestdoubleJS等。
单元测试
单元测试是基础、核心的测试方式,更加值得探究;
关键功能
上文说过,测试就是接收输入 => 执行测试 => 对比输出的过程,因此下面的关键功能除了断言都非必须
断言
测试中的必要环节,测试的核心就是断言的过程,即判断输入经过程序处理是否能输出期望的输出。最简单的断言工具莫过于node自带的assert功能,可以自行体验。
断言和异常已经错误的一个重要区别就是断言是不可恢复的错误,因此一般被用在测试环节,而非实际场景,实际场景需要为异常和错误进行各种补救来确保程序的鲁棒性。
const chai = require('chai');
const assert = chai.assert;
const expect = chai.expect;
const should = chai.should();
const str = 'hello chai'
assert.typeOf(str, 'string');
assert.typeOf(str, 'string', 'foo is a string');
expect(str).to.be.a('string');
expect(str).to.equal('hello chai');
str.should.be.a('string');
str.should.equal('hello chai');
以上是chai的三种不同的断言写法,断言中的重要过程是匹配,上述程序中的be、equal都是匹配器的一种,用法即表面意思。
很容易发现,expect语法和should语法其实没有太大的区别,都是链式结构,而assert语法则迥然不同,前两者属于BDD风格,后者是TDD风格,关于它们的描述,暂且放在题外话部分。
模拟
Mock是一个工程师不陌生的词汇,在开发过程中需要用到mock的时候基本都是测试阶段了,这是毋庸置疑的,但是mock并不是测试必要的部分。
一般来说,mock对象一定不会是测试的对象,那样是没有意义的。现在前端开发过程中,由于前后端的并行关系,一般都会mock后端数据用于自测,这是很有代表的一个场景,充分地表达了mock 的意义,即模拟需要测试对象的依赖对象,辅助对测试对象的测试,即模拟后端从而实现对前端的测试。
Mock对象对测试对象有以下几个积极意义:
提高测试覆盖率,mock对象可以构造出多种边界情况,充分覆盖测试用例; 保证开发的顺利进行,尤其是在上游环节出现阻塞的时候; 提升测试效率,真实场景下的依赖对象运行时间可能会很长,测试一次的成本较大;
Mock可以按照级别分为:方法级别、类级别、接口级别、服务级别。
常用的mock一般是接口级别的,即通过mock来模拟一个接口的行为,接收输入,给出输出,应用mock的过程,一般分为打桩、注入和调桩,桩指的是桩函数,即依赖对象的模拟函数。打桩即生成模拟函数,注入即将模拟函数替换真实函数,调桩即请求调用模拟函数。其中注入环节尤为重要,也存在多种实现方式,比如修改代码、修改配置、修改请求地址等等,各有利弊。
覆盖率
覆盖率是测试过程中的另一个指标,其实和单元测试关系不大,但是很多测试工具都配套了覆盖率的功能。测试直接的目的是为了测试程序是否符合预期,从这个角度出发,覆盖率不在测试范围内,因为他与符合预期没有什么关系。覆盖率其实和狭义的测试有共同的目的,那就是提高代码质量,一个完备的测试场景应当覆盖每一条语句,否则就存在冗余代码,所以覆盖率也被纳入了测试体系,当然,这里指的是代码覆盖率。百科给的定义说明了这个问题:覆盖率是一种判断测试严谨程度的方式,即测试的测试。
覆盖率也有很多种,除了代码覆盖率之外,还有类覆盖率,方法覆盖率等,计算方式也比较简单,直接将已经运行的语句、类、方法等和全部的语句、类、方法做比值即可。
快照
快照测试是是对比文件差异,类似于GitTree,而非图片
快照测试是单元测试的一个特例,用来检测对UI组件的意外更改,所以是一种针对UI组件的测试方式,原理比较简单,即对比测试发生前后的快照文件的差异来确定测试成功与否。但是测试的成功与否不能说明渲染逻辑的正确性,只表示代码的改动是否导致渲染结果发生改变,并给出发生改变的位置信息。
简单实现
如何实现一个单元测试框架?简单来说,需要按照给定的输入运行被测函数给出输出,再和预期的输出做比较得到测试结果,即模拟输入,运行测试,对比输出。
根据上面的三句话,我们已经可以写出一个简单的测试函数了:
module.exports.test0 = function (title, input, expected, fn) {
if (fn(...input) === expected) {
console.log(`✅ ${title} 通过测试!`);
} else {
console.log(`❎ ${title} 未通过测试!`);
}
};
这个测试函数虽然简单,但是可以运行被测函数,并进行断言,已经具备了测试工具的两个必要功能。但是明显有很多不足,比如不能捕获一些语言错误,并且只能对断言相等的情况。
捕获错误可以通过try-catch进行实现,但是这种机制只能在运行时捕获错误,比如语法错误就不能捕获,需要另外进行记录。
module.exports.test1 = function (title, input, expected, fn) {
try {
if (fn(...input) === expected) {
console.log(`✅ ${title} 通过测试!`);
} else {
console.log(`❎ ${title} 未通过测试!`);
}
} catch (e) {
console.log(`${title} 出现错误,无法正常运行!`);
console.error(e);
}
};
本文一直在强调,断言是测试的核心功能,并且有专业的断言库如chai.js等的存在。上文说到,断言的核心功能是匹配器,它指导了如何进行断言,匹配器多种多样以满足各种需要,比如基本类型的相等、引用类型的浅相等/深相等、是否包含指定元素等。
涉及到不同的匹配器,上文的函数就显得捉襟见肘,因为它指定了===作为匹配器,基于BDD模式的匹配器一般是链式的,不容易通过参数传入,因此匹配器相关的逻辑留给用户自行定义。
module.exports.test2 = function (title, callback) {
try {
callback();
console.log(`✅ ${title} 通过测试!`);
} catch (error) {
console.log(`❎ ${title} 未通过测试!`);
console.error(error);
}
};
匹配器和输入以及预期输出是强相关的,因此一并抽去,这样一来需要用户自己编写测试函数,这也是目前测试框架的基本用法。
相信读者很快会发现一个致命的问题,那就是这个测试函数只能用来测试callback函数是否正常运行,显然callback的正常运行和测试通过不能划等号,原因很明显,缺少了断言过程,如果用户自己不实现,那就丧失了测试工具的能力,但是显然这不该由用户实现,因此需要实现一个或者一些通用的断言函数,简单的实现如下:
module.exports.expect = function (result) {
return {
toBe(expected) {
if (result !== expected) {
throw new Error(`${result} 不等于 ${expected}`);
}
},
toBeType(expected) {
if (typeof result !== expected) {
throw new Error(`${result}的类型不等于 ${expected}`);
}
},
};
};
通过定义各种匹配器可以实现诸多断言功能,只要在callback函数中正确使用即能达到测试效果。
至此,一个极简的测试工具已经实现,当然和流行工具相比,还存在很多问题和诸多需要改善的点,比如:
被测函数一般被视为黑盒,测试函数单独作为文件引入函数,因此一些文件操作必不可少。 测试工具一般需要有终端运行的能力,并可以解析参数,定义输出流等。 测试容器(test函数)的运行需要考虑到安全问题,超时处理等等,一般是在是在vm虚拟机中局部进行的,相关的资源分配和管理是一个不容忽视的问题。 生成一份优雅的测试报告,尤其是可以很好的记录错误信息,错误信息的收集处理、测试函数运行时间记录等。 在单测过程中加入一些钩子函数,在特定的生命周期执行,比如记录测试时间之类。 更多的扩展功能,包括代码覆盖率、mock、快照等。
以上的很多功能都有较好的工具库支撑,下面实现了部分扩展:
理想的测试工具可以通过API和终端两种方式运行:
const fs = require('fs');
const path = require('path');
function run(filePath) {
const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
if (!fs.existsSync(actualPath)) {
console.error(`${filePath} 不存在!`);
return;
}
require(actualPath);
}
function runCli() {
let filePath = process.argv.slice(2)[0];
if (!filePath) {
filePath = 'test/test.js';
}
run(filePath);
}
module.exports = {
run,
runCli,
};
如果需要通过命令行进行其他配置,一些第三方库可能会更加高效,比如yargs等。
进一步,通过v8的vm进行环境隔离来执行测试用例,将test和expect注入,这样测试文件中可以不引入它们。
const vm = require('node:vm');
const { expect } = require('../src/expect');
const { test2 } = require('../src/test');
module.exports.runInVm = function (code) {
const context = {
test: test2,
expect,
};
vm.createContext(context);
vm.runInContext(code, context);
};
run函数和测试用例同步修改:
function run(filePath) {
const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
if (!fs.existsSync(actualPath)) {
console.error(`${filePath} 不存在!`);
return;
}
const code = fs.readFileSync(actualPath);
runInVm(code);
}
function sum(a, b) {
return a + b;
}
test('1+2=3', () => {
expect(sum(1, 2)).toBe(3);
});
test('typeof 1 === number', () => {
expect('10').toBeType('number');
});
接下来就是生成一份比较优雅的测试结果,可查看多文件所有测试用例的集成结果和细节:
module.exports.Result = class Result {
numTotalTestFiles = 0;
numPassTestFiles = 0;
numFailTestFiles = 0;
numTotalTestCases = 0;
numPassTestCases = 0;
numFailTestCases = 0;
startTime = 0;
endTime = 0;
testFilesResult = [];
};
function run(filePath) {
const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
if (!fs.existsSync(actualPath)) {
console.error(`${filePath} 不存在!`);
return;
}
const result = new Result();
result.startTime = Date.now();
if (fs.lstatSync(actualPath).isDirectory()) {
const files = fs.readdirSync(actualPath).filter(name => name.includes('test'));
result.numTotalTestFiles = files.length;
files.forEach(f => {
const code = fs.readFileSync(`${actualPath}/${f}`);
runInVm(code, result);
});
} else {
const code = fs.readFileSync(actualPath);
result.numTotalTestFiles = 1;
runInVm(code, result);
}
result.numTotalTestCases = result.numPassTestCases + result.numFailTestCases;
result.endTime = Date.now();
result.filePath = actualPath;
return result;
}
run函数中加入了集成结果统计,但是具体case的情况无法直接获取,因此引入发布订阅来解决:
module.exports.Emitter = class EventEmitter {
static Events = {};
static on(name, event) {
if (typeof event !== 'function') {
throw new TypeError('event is not function');
}
this.Events[name] = event;
}
static emit(eventName, ...args) {
this.Events[eventName](...args);
}
};
因为目前是按照文件运行,每次运行都会注册一次,直接替换赋值,执行过程中触发收集:
module.exports.runInVm = function (code, result) {
const context = {
test: test2,
expect,
};
const fileResult = {
startTime: Date.now(),
numPassCases: 0,
numFailCases: 0,
details: [],
};
Emitter.on('updateResult', res => {
if (res.status === 'pass') {
fileResult.numPassCases++;
result.numPassTestCases++;
} else {
fileResult.numFailCases++;
result.numFailTestCases++;
}
fileResult.details.push(res);
});
vm.createContext(context);
vm.runInContext(code, context);
fileResult.endTime = Date.now();
fileResult.numTotalCases = fileResult.numPassCases + fileResult.numFailCases;
fileResult.status = fileResult.numFailCases === 0 ? 'pass' : 'fail';
if (fileResult.status === 'fail') {
result.numFailTestFiles++;
} else {
result.numPassTestFiles++;
}
result.testFilesResult.push(fileResult);
};
在runInVm内加入文件的case结果汇总,但是要先注册case内容部的结果收集函数,最终可以得到完整的测试结果:
结果包括测试函数运行过程中的输出和最终的结果汇总,观察细致的读者可以看到上图中已经存在设置配置,这是本次扩展实现的最后一部分了,就是加入运行配置,目前仅加入了结果显示和保存:
function runCli() {
const args = process.argv.slice(2);
const filePath = args[0] ? args[0] : 'test/test.js';
const result = run(filePath);
if (args.includes('--showJson')) {
console.log(JSON.stringify(result, null, 2));
} else if (args.includes('--showResult')) {
console.log(result);
}
if (args.includes('--saveJson')) {
const _path = args[args.indexOf('--saveJson') + 1];
if (!_path) {
if (fs.lstatSync(result.filePath).isDirectory()) {
fs.writeFileSync(`${result.filePath}/result.json`, JSON.stringify(result, null, 2));
} else {
const arr = result.filePath.split('/');
arr.length--;
fs.writeFileSync(`${arr.join('/')}/result.json`, JSON.stringify(result, null, 2));
}
return;
}
const savePath = _path.startsWith('/') ? `${_path}` : path.join(process.cwd(), _path);
if (!fs.existsSync(savePath)) {
if (savePath.endsWith('.json')) {
fs.writeFileSync(savePath, JSON.stringify(result, null, 2));
} else {
fs.writeFileSync(`${savePath}.json`, JSON.stringify(result, null, 2));
}
return;
}
if (fs.lstatSync(savePath).isDirectory()) {
fs.writeFileSync(`${savePath}/result.json`, JSON.stringify(result, null, 2));
} else {
fs.writeFileSync(savePath, JSON.stringify(result, null, 2));
}
}
}
上述代码只在runCli中实现了配置,调用API不能支持,因此需要将配置抽象出来,过程和result比较类似,然后将功能收集到run中,还runcli一片清明。
module.exports.Config = class Config {
showResult;
showJson;
saveJson;
savePath;
constructor(options) {
this.showJson = options.showJson || false;
this.showResult = options.showResult || false;
this.saveJson = options.saveJson || false;
this.savePath = options.savePath;
}
};
最终的项目结构如下:
总结一下,这个小的测试工具实现了测试关键的隔离运行、断言、结果收集汇总、命令行解析运行功能,但是没有实现额外的包括模拟、覆盖率、快照等扩展,并且只是一个大概的运行逻辑,很多细节经不起推敲,比如对于callback中出现语法错误目前还没有实现等等,总之,革命尚未成功,同志还需努力!希望与大家共勉之!
题外话
Test-Runner
Jest的使用目前只开放了终端方式,API调用说是会开放; Jest的结果报告不适合作为API调用的返回结果,原因是对各种错误情况的返回字段不统一。
为了解决上述的问题,之前写过一个简单test-runner,可以直接在node项目中引入,调用执行,有兴趣的话,可以通过 npm install jestprogramrunner耍一下;
declare namespace JPR {
/**
*
* @param filePath 接收单个测试文件或者一组测试文件,可使用正则模式
* @param config 可参照Jest配置
* @param thread 是否启动线程执行,默认为false
* @returns 返回一个对象,包括解析后的结果result和原始结果rawResult
*/
declare function run(filePath:string | string[], thread?:boolean, config?:Config.Argv):Promise<{
result:Result,
rawResult:AggregatedResult
}>;
declare function parseResult(data:AggregatedResult):Result
}
export default JPR;
TDD和BDD
TDD:测试驱动开发(Test-Driven Development) BDD:行为驱动开发(Behavior Driven Development)
本质上BDD是TDD的一种补充,将测试行为更加细化,举例说明,对点击按钮的行为来说:TDD模式就是点击按钮,会触发什么事件;而BDD模式则是点击按钮要展现何种效果。
官方解释:测试驱动开发(TDD)是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。TDD的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析,设计,质量控制量化的过程。TDD首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例对功能的过程和接口进行设计,而测试框架可以持续进行验证。
从上述解释中可以提炼出TDD开发的一般流程:
传统编码方式 需求分析,想不清楚细节,管他呢,先开始写 发现需求细节不明确,去跟业务人员确认 确认好几次终于写完所有逻辑 运行起来测试一下,靠,果然不工作,调试 调试好久终于工作了 转测试,QA 测出 bug,debug, 打补丁 终于,代码可以工作了 一看代码烂的像坨屎,不敢动,动了还得手工测试,还得让 QA 测试,还得加班... TDD 编码方式 先分解任务,分离关注点 列 Example,用实例表达需求,澄清需求细节 写测试,只关注需求,程序的输入输出,不关心中间过程 写实现,不考虑别的需求,用最简单的方式满足当前这个小需求即可 重构,用手法消除代码里的坏味道 写完,运行测试,基本没什么问题,有问题补个用例,修复 转测试,小问题,补用例,修复 代码整洁且用例齐全
通过对比,优势自然不用说,缺点也很明显,就是需要写很多测试用例,TDD力求测试用例覆盖完全,实际的开发中,可以按照项目大小、排期时间、需求清晰明确与否等选择合适的方式。
参考
ava vs jasmine vs jest vs mocha vs tape | npm trends: https://www.npmtrends.com/ava-vs-jasmine-vs-jest-vs-mocha-vs-tape
[2]Testing Library: https://testing-library.com/docs/
[3]assert: https://nodejs.org/dist/latest-v18.x/docs/api/assert.html
[4]Jest, part 1: 什么是快照(snapshot): https://blog.axiu.me/jest-what-is-snapshot/
[5]前端测试框架调研丨【WEB前端大作战】-云社区-华为云: https://bbs.huaweicloud.com/blogs/257894
[6]JS测试框架Jest/Mocha/Ava的简单比较 - 掘金: https://juejin.cn/post/6844904009887645709
[7]代码覆盖率工具 Istanbul 入门教程 - 阮一峰的网络日志: https://www.ruanyifeng.com/blog/2015/06/istanbul.html
[8]github.com: https://github.com/Wscats/jest-tutorial
[9]trycatch 不能捕获运行时异常_面试官:用一句话描述 JS 异常是否能被 try catch 捕获到 ?... - 掘金: https://juejin.cn/post/7021889887615844360
[10]Node.js 中如何收集和解析命令行参数_傲娇的koala的博客-CSDN博客: https://blog.csdn.net/xgangzai/article/details/113577572
[11]深度解读 - TDD(测试驱动开发): https://www.jianshu.com/p/62f16cd4fef3
❤️ 谢谢支持
以上便是本次分享的全部内容,希望对你有所帮助^_^
喜欢的话别忘了 分享、点赞、收藏 三连哦~。
欢迎关注公众号 ELab团队 收货大厂一手好文章~
我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。
我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。
欢迎感兴趣的同学在评论区或使用内推码内推到作者部门拍砖哦 🤪
字节跳动校/社招投递链接: https://job.toutiao.com/s/2NuCs3t
内推码:5N7FGNN