逆向工程:如何用 JavaScript 构建测试库

2025-06-07

逆向工程:如何用 JavaScript 构建测试库

在Twitter上关注我,很高兴接受您对主题或改进的建议/Chris

我知道你在想什么。市面上有那么多测试库,我竟然还要自己搭建一个?听我说完。这篇文章主要讲解如何进行逆向工程,并了解底层机制。为什么?只是为了更好地理解和运用你所使用的库。

先说清楚一点。我并不打算完整地实现一个测试库,只是想看一下公共 API,大致了解一下是怎么回事,然后开始实现。我希望通过这样做,对整体架构有所了解,包括如何规划、如何扩展,以及哪些部分比较棘手、哪些比较简单。

希望您旅途愉快:)

我们将介绍以下内容:

  • 为什么,尝试解释逆向工程的所有好处
  • 我们将要建设什么,不建设什么
  • 构建,慢慢带你完成构建的步骤

 为什么

很多年前,在我软件开发生涯的初期,我曾问过一位资深开发人员,他们是如何进步的。答案不止一个,但有一件事格外突出,那就是逆向工程,或者更确切地说,是重新创建他们正在使用或感兴趣的库或框架。

听起来你好像在重复造轮子。这有什么好处呢?我们不是已经有足够多的库来做同样的事情了吗?

当然,这种说法有其道理。不要仅仅因为不喜欢某个库的某种风格就去构建它,除非你真的需要,不过有时候你确实需要。

那么什么时候?

当你试图在你的职业上做得更好时。

听起来很模糊

嗯,部分是的。有很多方法可以让你变得更好。我认为,要真正理解某样东西,仅仅使用它还不够——你需要构建它

什么?全部?

取决于库或框架的大小。有些库或框架足够小,值得全部构建。但大多数库或框架并非如此。尝试实现某些东西很有价值,很多东西一开始就能理解,只是偶尔会遇到瓶颈。这就是这次练习的目的,尝试了解更多。

什么

我们在一开始提到了构建一个测试库。什么测试库?好吧,让我们来看看大多数测试库在 JavaScript 中的样子。它们通常看起来像这样:

describe('suite', () => {
  it('should be true', () => {
    expect(2 > 1).toBe(true)
  })
})
Enter fullscreen mode Exit fullscreen mode

这是我们将要构建的范围,使上述内容发挥作用,并在此过程中对架构进行评论,甚至可能加入一个库以使其更漂亮:)

让我们开始吧。

构建

好吧。如果你建好了,他们就会来

当然?

您知道电影《梦幻之地》吗?

无论爷爷无聊

期待并坚持我们的价值观

让我们从最内层的语句——expect()函数——开始。通过观察一次调用,我们可以学到很多东西:

expect(2 > 1).toBe(true)
Enter fullscreen mode Exit fullscreen mode

expect()看起来像是一个接受 的函数boolean。它似乎返回一个对象,该对象toBe()本身带有一个方法,该方法还可以将 中的值expect()toBe()传入的值进行比较。我们试着画出它的示意图:

function expect(actual) {
  return {
    toBe(expected) { 
      if(actual === expected){ 
        /* do something*/ 
      } else {
        /* do something else*/
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

此外,我们还应该考虑,如果匹配成功或失败,应该生成某种语句。因此,还需要一些代码:

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 
Enter fullscreen mode Exit fullscreen mode

请注意,该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)
Enter fullscreen mode Exit fullscreen mode

好的,所以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();
Enter fullscreen mode Exit fullscreen mode

我不知道你是怎么想的,但我感觉我的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);
}
Enter fullscreen mode Exit fullscreen mode

我们的测试方法

从我们的设想来看,它应该是这样的:

it('test method', () => {
  expect(3).toBe(2)
})
Enter fullscreen mode Exit fullscreen mode

好的,通过逆向工程,我们几乎可以编写我们的it()方法:

function it(testName, fn) {
  console.log(`test: ${testName}`);
  fn();
}
Enter fullscreen mode Exit fullscreen mode

好吧,我们先停下来思考一下。我们想要什么样的行为?我确实见过一些单元测试库,如果出现问题就会停止运行测试。我想,如果你有 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}`)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

这意味着我们的it()函数需要捕获任何错误,如下所示:

function it(testName, fn) {
  console.log(`test: ${testName}`);
  try {
    fn();
  } catch(err) {
    console.log(err);
    throw new Error('test run failed');
  }

}
Enter fullscreen mode Exit fullscreen mode

正如您上面所见,我们不仅捕获了错误并记录下来,还重新抛出了错误以终止运行本身。同样,主要原因是我们认为继续运行毫无意义。您可以按照自己认为合适的方式实现这一点。

描述一下我们的测试套件

好的,我们介绍了编写代码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();
  })
})
Enter fullscreen mode Exit fullscreen mode

至于实现,我们知道失败的测试会引发错误,因此我们需要捕获它以免导致整个程序崩溃:

function describe(suiteName, fn) {
  try {
    console.log(`suite: ${suiteName}`);
    fn();
  } catch(err) {
    console.log(err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

运行代码

此时我们的完整代码应该是这样的:

// 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);
  })
})
Enter fullscreen mode Exit fullscreen mode

当在终端中运行时node app.js,应该呈现如下效果:

让它变得漂亮

上面的代码看起来好像能用,但看起来无聊了。那我们能做些什么呢?颜色,丰富的颜色会让效果更好。使用这个库,chalk我们可以让它更生动一些:

npm install chalk --save
Enter fullscreen mode Exit fullscreen mode

好的,接下来让我们添加一些颜色和一些制表符和空格,我们的代码应该如下所示:

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);
  })
})
Enter fullscreen mode Exit fullscreen mode

运行时渲染如下:

概括

我们的目标是研究一个相当小的库,比如单元测试库。通过查看代码,我们可以推断出它的底层结构。

我们创建了一个起点。话虽如此,我们需要意识到大多数单元测试库还附带许多其他功能,例如处理异步测试、多个测试套件、模拟、间谍等等matchers。尝试了解你每天使用的内容会有很多收获,但请记住,你不必完全重新发明它就能获得很多见解。

我希望您可以使用此代码作为起点,并尝试一下,从头开始或扩展,选择权在您手中。

这样做的另一个结果可能是,您了解得足够多,可以帮助 OSS 并改进现有的库之一。

请记住,如果你建造,他们就会来:

文章来源:https://dev.to/itnext/reverse-engineering-how-you-can-build-a-test-library-53e3
PREV
React.js 令人惊叹的渲染属性模式 — — 生命周期消失了!
NEXT
如何学习 Node.js I/O、文件和路径