逆向工程:如何用 JavaScript 构建测试库
在Twitter上关注我,很高兴接受您对主题或改进的建议/Chris
我知道你在想什么。市面上有那么多测试库,我竟然还要自己搭建一个?听我说完。这篇文章主要讲解如何进行逆向工程,并了解底层机制。为什么?只是为了更好地理解和运用你所使用的库。
先说清楚一点。我并不打算完整地实现一个测试库,只是想看一下公共 API,大致了解一下是怎么回事,然后开始实现。我希望通过这样做,对整体架构有所了解,包括如何规划、如何扩展,以及哪些部分比较棘手、哪些比较简单。
希望您旅途愉快:)
我们将介绍以下内容:
- 为什么,尝试解释逆向工程的所有好处
- 我们将要建设什么,不建设什么
- 构建,慢慢带你完成构建的步骤
为什么
很多年前,在我软件开发生涯的初期,我曾问过一位资深开发人员,他们是如何进步的。答案不止一个,但有一件事格外突出,那就是逆向工程,或者更确切地说,是重新创建他们正在使用或感兴趣的库或框架。
听起来你好像在重复造轮子。这有什么好处呢?我们不是已经有足够多的库来做同样的事情了吗?
当然,这种说法有其道理。不要仅仅因为不喜欢某个库的某种风格就去构建它,除非你真的需要,不过有时候你确实需要。
那么什么时候?
当你试图在你的职业上做得更好时。
听起来很模糊
嗯,部分是的。有很多方法可以让你变得更好。我认为,要真正理解某样东西,仅仅使用它还不够——你需要构建它。
什么?全部?
取决于库或框架的大小。有些库或框架足够小,值得全部构建。但大多数库或框架并非如此。尝试实现某些东西很有价值,很多东西一开始就能理解,只是偶尔会遇到瓶颈。这就是这次练习的目的,尝试了解更多。
什么
我们在一开始提到了构建一个测试库。什么测试库?好吧,让我们来看看大多数测试库在 JavaScript 中的样子。它们通常看起来像这样:
describe('suite', () => {
it('should be true', () => {
expect(2 > 1).toBe(true)
})
})
这是我们将要构建的范围,使上述内容发挥作用,并在此过程中对架构进行评论,甚至可能加入一个库以使其更漂亮:)
让我们开始吧。
构建
好吧。如果你建好了,他们就会来。
当然?
您知道电影《梦幻之地》吗?
无论爷爷无聊
期待并坚持我们的价值观
让我们从最内层的语句——expect()
函数——开始。通过观察一次调用,我们可以学到很多东西:
expect(2 > 1).toBe(true)
expect()
看起来像是一个接受 的函数boolean
。它似乎返回一个对象,该对象toBe()
本身带有一个方法,该方法还可以将 中的值expect()
与toBe()
传入的值进行比较。我们试着画出它的示意图:
function expect(actual) {
return {
toBe(expected) {
if(actual === expected){
/* do something*/
} else {
/* do something else*/
}
}
}
}
此外,我们还应该考虑,如果匹配成功或失败,应该生成某种语句。因此,还需要一些代码:
function expect(actual) {
return {
toBe(expected) {
if(expected === actual){
console.log(`Succeeded`)
} else {
console.log(`Fail - Actual: ${actual}, Expected: ${expected}`)
}
}
}
}
expect(true).toBe(true) // Succeeded
expect(3).toBe(2) // Fail - Actual: 3, Expected: 2
请注意,该else
语句包含更专业的信息并提示我们失败的原因。
像这样将两个值相互比较的方法toBe()
被称为matchers
。让我们尝试添加另一个匹配器toBeTruthy()
。原因是JavaScript 中的“真值”匹配很多值,我们不想toBe()
对所有值都使用匹配器。
所以我们很懒惰?
是的,这是最好的理由:)
这条规则是,JavaScript 中任何被认为是真值的东西都应该渲染成功,其他任何东西都应该渲染失败。我们来稍微作弊一下,去 MDN 看看什么被认为是真值:
if (true)
if ({})
if ([])
if (42)
if ("0")
if ("false")
if (new Date())
if (-42)
if (12n)
if (3.14)
if (-3.14)
if (Infinity)
if (-Infinity)
好的,所以if
语句中的所有内容都计算为true
。是时候添加所述方法了:
function expect(actual) {
return {
toBe(expected) {
if(expected === actual){
console.log(`Succeeded`)
} else {
console.log(`Fail - Actual: ${val}, Expected: ${expected}`)
}
},
toBeTruthy() {
if(actual) {
console.log(`Succeeded`)
} else {
console.log(`Fail - Expected value to be truthy but got ${actual}`)
}
}
}
}
expect(true).toBe(true) // Succeeded
expect(3).toBe(2) // Fail - Actual: 3, Expected: 2
expect('abc').toBeTruthy();
我不知道你是怎么想的,但我感觉我的expect()
函数开始包含很多东西了。所以,让我们把它移到matchers
一个Matchers
类中,就像这样:
class Matchers {
constructor(actual) {
this.actual = actual;
}
toBe(expected) {
if(expected === this.actual){
console.log(`Succeeded`)
} else {
console.log(`Fail - Actual: ${this.actual}, Expected: ${expected}`)
}
}
toBeTruthy() {
if(this.actual) {
console.log(`Succeeded`)
} else {
console.log(`Fail - Expected value to be truthy but got ${this.actual}`)
}
}
}
function expect(actual) {
return new Matchers(actual);
}
我们的测试方法
从我们的设想来看,它应该是这样的:
it('test method', () => {
expect(3).toBe(2)
})
好的,通过逆向工程,我们几乎可以编写我们的it()
方法:
function it(testName, fn) {
console.log(`test: ${testName}`);
fn();
}
好吧,我们先停下来思考一下。我们想要什么样的行为?我确实见过一些单元测试库,如果出现问题就会停止运行测试。我想,如果你有 200 个单元测试(当然,一个文件中不应该有 200 个测试 :)),你肯定不想等它们完成,最好直接告诉我哪里出了问题,这样我就能修复它。为了实现后者,我们需要稍微调整一下匹配器:
class Matchers {
constructor(actual) {
this.actual = actual;
}
toBe(expected) {
if(expected === actual){
console.log(`Succeeded`)
} else {
throw new Error(`Fail - Actual: ${val}, Expected: ${expected}`)
}
}
toBeTruthy() {
if(actual) {
console.log(`Succeeded`)
} else {
console.log(`Fail - Expected value to be truthy but got ${actual}`)
throw new Error(`Fail - Expected value to be truthy but got ${actual}`)
}
}
}
这意味着我们的it()
函数需要捕获任何错误,如下所示:
function it(testName, fn) {
console.log(`test: ${testName}`);
try {
fn();
} catch(err) {
console.log(err);
throw new Error('test run failed');
}
}
正如您上面所见,我们不仅捕获了错误并记录下来,还重新抛出了错误以终止运行本身。同样,主要原因是我们认为继续运行毫无意义。您可以按照自己认为合适的方式实现这一点。
描述一下我们的测试套件
好的,我们介绍了编写代码it()
,expect()
甚至还添加了几个匹配器函数。所有测试库都应该有一个套件的概念,用来表示这是一组属于同一类的测试。
让我们看看代码是什么样的:
describe('our suite', () => {
it('should fail 2 != 1', () => {
expect(2).toBe(1);
})
it('should succeed', () => { // technically it wouldn't get here, it would crash out after the first test
expect('abc').toBeTruthy();
})
})
至于实现,我们知道失败的测试会引发错误,因此我们需要捕获它以免导致整个程序崩溃:
function describe(suiteName, fn) {
try {
console.log(`suite: ${suiteName}`);
fn();
} catch(err) {
console.log(err.message);
}
}
运行代码
此时我们的完整代码应该是这样的:
// app.js
class Matchers {
constructor(actual) {
this.actual = actual;
}
toBe(expected) {
if (expected === this.actual) {
console.log(`Succeeded`)
} else {
throw new Error(`Fail - Actual: ${this.actual}, Expected: ${expected}`)
}
}
toBeTruthy() {
if (actual) {
console.log(`Succeeded`)
} else {
console.log(`Fail - Expected value to be truthy but got ${this.actual}`)
throw new Error(`Fail - Expected value to be truthy but got ${this.actual}`)
}
}
}
function expect(actual) {
return new Matchers(actual);
}
function describe(suiteName, fn) {
try {
console.log(`suite: ${suiteName}`);
fn();
} catch(err) {
console.log(err.message);
}
}
function it(testName, fn) {
console.log(`test: ${testName}`);
try {
fn();
} catch (err) {
console.log(err);
throw new Error('test run failed');
}
}
describe('a suite', () => {
it('a test that will fail', () => {
expect(true).toBe(false);
})
it('a test that will never run', () => {
expect(1).toBe(1);
})
})
describe('another suite', () => {
it('should succeed, true === true', () => {
expect(true).toBe(true);
})
it('should succeed, 1 === 1', () => {
expect(1).toBe(1);
})
})
当在终端中运行时node app.js
,应该呈现如下效果:
让它变得漂亮
上面的代码看起来好像能用,但看起来太无聊了。那我们能做些什么呢?颜色,丰富的颜色会让效果更好。使用这个库,chalk
我们可以让它更生动一些:
npm install chalk --save
好的,接下来让我们添加一些颜色和一些制表符和空格,我们的代码应该如下所示:
const chalk = require('chalk');
class Matchers {
constructor(actual) {
this.actual = actual;
}
toBe(expected) {
if (expected === this.actual) {
console.log(chalk.greenBright(` Succeeded`))
} else {
throw new Error(`Fail - Actual: ${this.actual}, Expected: ${expected}`)
}
}
toBeTruthy() {
if (actual) {
console.log(chalk.greenBright(` Succeeded`))
} else {
throw new Error(`Fail - Expected value to be truthy but got ${this.actual}`)
}
}
}
function expect(actual) {
return new Matchers(actual);
}
function describe(suiteName, fn) {
try {
console.log('\n');
console.log(`suite: ${chalk.green(suiteName)}`);
fn();
} catch (err) {
console.log(chalk.redBright(`[${err.message.toUpperCase()}]`));
}
}
function it(testName, fn) {
console.log(` test: ${chalk.yellow(testName)}`);
try {
fn();
} catch (err) {
console.log(` ${chalk.redBright(err)}`);
throw new Error('test run failed');
}
}
describe('a suite', () => {
it('a test that will fail', () => {
expect(true).toBe(false);
})
it('a test that will never run', () => {
expect(1).toBe(1);
})
})
describe('another suite', () => {
it('should succeed, true === true', () => {
expect(true).toBe(true);
})
it('should succeed, 1 === 1', () => {
expect(1).toBe(1);
})
})
运行时渲染如下:
概括
我们的目标是研究一个相当小的库,比如单元测试库。通过查看代码,我们可以推断出它的底层结构。
我们创建了一个起点。话虽如此,我们需要意识到大多数单元测试库还附带许多其他功能,例如处理异步测试、多个测试套件、模拟、间谍等等matchers
。尝试了解你每天使用的内容会有很多收获,但请记住,你不必完全重新发明它就能获得很多见解。
我希望您可以使用此代码作为起点,并尝试一下,从头开始或扩展,选择权在您手中。
这样做的另一个结果可能是,您了解得足够多,可以帮助 OSS 并改进现有的库之一。
请记住,如果你建造,他们就会来:
文章来源:https://dev.to/itnext/reverse-engineering-how-you-can-build-a-test-library-53e3