查看原文
其他

Node.js 原生 Test Runner 综合指南

FEDLAB FED实验室 2024-02-12
大家好,今天是坚持日更的第108天。相信坚持的力量!欢迎点赞关注。

Node.js 原生测试运行器 Test Runner 在 20.x 转为稳定版本,可以在生产环境中使用。

本文将从以下几个方面来探索 Node.js 测试运行器:

  • 为什么需要内置的 Test Runner
    • 与 Jest 和 Mocha 主要特性对比
    • 使用 Node.js 测试运行器的优势
  • 使用 Test Runner 编写测试用例
    • 基本测试
    • 使用断言
    • skiping 测试
    • Subtests
    • 使用 hooks
    • 高级语法
    • 测试报告

在本章的最后会对以上的探索进行一个总结,并阐述个人对新的 Test Runner 的看法。

1.为什么需要内置 Test Runner?

Node.js测试运行器的目的是提供一组有限的测试功能,可以用于测试项目,而不需要第三方依赖。它还将提供一组基本的原语,供测试框架使用来标准化。

直到现在,Node.js中的所有测试运行器都是作为第三方包构建的,比如Mocha、Jasmine或Jest。这意味着要在项目中编写和运行测试,你必须首先选择添加一个依赖项。依赖项需要维护,并且可能给你的本地配置和CI/CD流水线增加复杂性。其他语言,如Ruby、Go和Python,都有自己内置的测试运行器。Deno和Bun也提供了一个测试运行器。因此,提供一个无依赖、内置的运行器似乎是很自然的选择。

1.1.与 Jest 和 Mocha 主要特性对比

下面是原生 Node 测试运行器、Jest 和 Mocha 所提供功能的比较:

FeaturesNode.js test runnerJestMocha
SpiesBuilt-inBuilt-inSinon
StubsBuilt-inBuilt-inSinon
Code coverageExperimentalFull featureFull feature
Assertion libraryAssertBuilt-inChai is widely used
Fake timersBuilt-inBuilt-inSinon
Test hooksPresentPresentPresent
Asynchronous testingPresentPresentPresent

1.2.Node.js 测试运行器的优势

与本地 Node 测试运行程序相比,Jest 和 Mocha 在生态系统中存在的时间要长得多,因此它们拥有庞大的开发人员社区。不过,Node 测试运行程序确实具有其他框架可能缺乏的一些优势。

Node.js 测试运行程序是 Node 核心的一部分,因此它比其他选项更快也是情理之中的。测试运行器针对 Node 进行了优化,不像其他测试框架必须将浏览器考虑在内。

最后,Jest这个最流行的JavaScript测试框架在设置测试环境时会破坏instanceof运算符。使用平台内置的测试运行器应该比这更可预测。

下面我将用一段代码来进行测试,主要阐述测试运行期的工作原理。本文用例使用的 Node.js 版本为 20.6.1。

2.Test Runner 编写测试用例

我们首先创建使用命令来快速创建一个 Node.js 项目,并创建两个文件 stack.mjs 和 stack.test.mjs。注意我这里使用了模块化规范及文件名后缀 .mjs


mkdir test-runner

cd test-runner

touch stack.mjs && touch stack.test.mjs

您可以立即运行测试命令:

➜ node --test
ℹ tests 0
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8.249202

直接运行 node --test,从测试日志看到,所有的文件可以运行成功。我们无需安装任何依赖项,甚至我们连 package.json 都没有创建。

当我们运行 node --test 时,测试运行器会查找可能成为测试的文件。默认情况下,这包括所有带有后缀名为 .js、.cjs 或 .mjs 的JavaScript文件,匹配以下任何模式的文件:

  • 目录名为 test 的目录内的文件
  • 文件名为test.js、test.cjs 或 test.mjs 的文件
  • 以 test- 开头的文件
  • 以 .test、-test 或 _test 结尾的文件

你也可以显式地将文件和目录的列表传递给node --test命令。

测试运行器发现的每个文件都会在一个单独的子进程中执行。如果该进程以代码0退出,则测试被视为通过。这就是为什么我们的空文件已经显示为通过测试的原因。

2.1.基本测试

在 stack.test.mjs 中从 node:test 导入测试函数。

import { test } from "node:test";

node:test 是标准库模块,您可以导入并用于在测试文件中创建测试。请注意,您必须在此处使用 node:test,而不能像使用其他标准库模块那样只使用 test。

test() 函数允许我们为特定的测试命名,并创建子测试组。将名称和一个函数传递给测试,如果函数在没有抛出错误的情况下完成,则被认为是通过的。在你的测试文件中写入以下内容:

import { test } from "node:test";

test("will pass", () => {
console.log("hello world");
});

test("will fail", () => {
throw new Error("fail");
});

使用 node --test 运行测试,您将看到一次通过和一次失败。

➜ node --test
hello world
✔ will pass (2.667564ms)
✖ will fail (1.227886ms)
Error: fail
at TestContext.<anonymous> (file:///Users/test-runner/stack.test.mjs:8:9)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:581:25)
at Test.processPendingSubtests (node:internal/test_runner/test:326:18)
at Test.postRun (node:internal/test_runner/test:656:19)
at Test.run (node:internal/test_runner/test:615:10)
at async startSubtest (node:internal/test_runner/harness:207:3)
ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 121.78215
✖ failing tests:
✖ will fail (1.227886ms)
Error: fail
at TestContext.<anonymous> (file:///Users/test-runner/stack.test.mjs:8:9)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:581:25)
at Test.processPendingSubtests (node:internal/test_runner/test:326:18)
at Test.postRun (node:internal/test_runner/test:656:19)
at Test.run (node:internal/test_runner/test:615:10)
at async startSubtest (node:internal/test_runner/harness:207:3)

手动抛出错误并不是编写测试的最富表达力和高效的方法。幸运的是,Node有一个断言模块可以使用。当来自 node:assert 的断言失败时,它会抛出一个 AssertionError,这与测试运行程序很好地配合使用。

2.2.使用断言

断言模块有两种模式,严格模式和传统模式。传统模式在等式断言中使用 == 运算符,但不推荐使用 ==。我鼓励使用严格模式。我们可以使用 node:assert 来重写上述测试,如下所示:

import { test } from "node:test";
import assert from "node:assert/strict";

test("will pass", () => {
assert.ok("hello world");
});

test("will fail", () => {
assert.fail("fail");
});

现在运行 node --test,您将看到比普通错误信息更多的 Error 信息。

➜ node --test
✔ will pass (1.615929ms)
✖ will fail (2.366525ms)
AssertionError [ERR_ASSERTION]: fail
at TestContext.<anonymous> (file:///Users/test-runner/stack.test.mjs:9:10)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:581:25)
at Test.processPendingSubtests (node:internal/test_runner/test:326:18)
at Test.postRun (node:internal/test_runner/test:656:19)
at Test.run (node:internal/test_runner/test:615:10)
at async startSubtest (node:internal/test_runner/harness:207:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: undefined,
operator: 'fail'
}

ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 124.618465

✖ failing tests:

✖ will fail (2.366525ms)
AssertionError [ERR_ASSERTION]: fail
at TestContext.<anonymous> (file:///Users/test-runner/stack.test.mjs:9:10)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:581:25)
at Test.processPendingSubtests (node:internal/test_runner/test:326:18)
at Test.postRun (node:internal/test_runner/test:656:19)
at Test.run (node:internal/test_runner/test:615:10)
at async startSubtest (node:internal/test_runner/harness:207:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: undefined,
operator: 'fail'
}

Assert 有很多有用的断言,我们可以查看官方文档使用,我最欢的断言是 assert.deepStrictEqual(actual, expected[, message]) ,用来判断非原始对象相等。

2.3.Skipping 测试

test() 函数将一个对象作为可选参数。您可以用它来跳过测试或只运行某些测试。

test("will pass", { only: true }, () => {
assert.ok("hello world");
});

test("will fail", { skip: true }, () => {
assert.fail("fail");
});

您可以随时跳过测试。不过,只有在使用 --test-only 标志运行测试套件时,才会优先使用 only 选项。还有一个 todo 选项,它仍会运行测试,但会将其标记为 "todo" 测试。这些选项还有一个快捷方式,可以调用 test.skip、test.only 或 test.todo 来获得相同的结果。

在命令行中,使用 --test-name-pattern 可以传递一个字符串来匹配测试名称。只有匹配的测试名称才会被运行。因此,以下命令只会运行名为 "will pass" 的测试。

➜ node --test --test-name-pattern "will pass"
✔ will pass (1.606319ms)
﹣ will fail (0.2959ms) # test name does not match pattern
ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 1
ℹ todo 0
ℹ duration_ms 114.027203

还有一些其他选项:

  • timeout:如果测试没有在设定时间内完成,则测试失败
  • concurrency:如果提供了一个数字,那么这么多测试将在应用程序线程内并行运行。如果为 "true",所有计划的异步测试都将在线程内并行运行。如果为 "false",则一次只运行一个测试。如果未指定,子测试将从其父测试继承此值。默认值:false。
  • signal:这是一个 AbortSignal 信号,可以传递给测试以在进程中取消测试。

2.4.Subtests

只需使用测试功能,你还可以将测试分组为子测试。让我们在开始为堆栈实现建立测试时探讨一下这个问题。制作子测试时,根测试函数应接收一个测试上下文参数。你必须在上下文对象上调用 test 来添加子测试。由于测试函数会返回一个 promise,因此你需要等待每个测试。如果根测试先于子测试完成,则会将未完成的测试标记为失败。

stack.mjs 更新如下:

export default class Stack {
constructor() {
this.items = [];
}

size() {
return this.items.length;
}

push(item) {
this.items.push(item);
}
}

stack.test.mjs 文件更新如下:

import { test } from 'node:test';
import assert from "node:assert/strict";
import Stack from './stack.mjs';
test("a new stack", async (context) => {
const stack = new Stack();

await context.test("is empty", () => {
assert.equal(stack.size(), 0);
});

await context.test("is not empty after push", () => {
stack.push("item");
assert.equal(stack.size(), 1);
});
});

运行 node --test,可以看到测试报告信息有两个测试子项:

➜ node --test
▶ a new stack
✔ is empty (0.851226ms)
✔ is not empty after push (0.315894ms)
▶ a new stack (4.721426ms)

ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 106.72762

2.5.测试 Hooks

测试运行器提供了在测试前后运行的 hook。我们可以使用 beforeEach hook 为每个测试定义一个新的堆栈对象。

stack.mjs 文件更新:

export default class Stack {
constructor() {
this.items = [];
}

size() {
return this.items.length;
}

push(item) {
this.items.push(item);
}

pop() {
return this.items.pop();
}
}

stack.test.mjs 文件更新:

import { test } from 'node:test';
import assert from "node:assert/strict";
import Stack from './stack.mjs';

test("a new stack", async (context) => {
let stack;

context.beforeEach(() => {
stack = new Stack();
});

await context.test("is empty", () => {
assert.equal(stack.size(), 0);
});

await context.test("is not empty after push", () => {
stack.push("item");
assert.equal(stack.size(), 1);
});

await context.test("pop returns undefined for an empty stack", () => {
assert.equal(stack.pop(), undefined);
});
});

2.6.高级语法

测试运行器还提供了 describe/it 语法。使用 describe() 时,你不需要使用 await,也不需要使用 context,你可以导入像 beforeEach 这样的 hoot,并在用例中直接使用它们。describe() 可以嵌套使用。

用 describe/it 语法重写 Stack 测试用例:

import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import Stack from './stack.mjs';

describe("a new stack", async (context) => {
let stack;

beforeEach(() => {
stack = new Stack();
});

it("is empty", () => {
assert.equal(stack.size(), 0);
});

it("is not empty after push", () => {
stack.push("item");
assert.equal(stack.size(), 1);
});

it("pop returns undefined for an empty stack", () => {
assert.equal(stack.pop(), undefined);
});
});

使用 node --test 运行如下:

➜ node --test
▶ a new stack
✔ is empty (1.047994ms)
✔ is not empty after push (0.47216ms)
✔ pop returns undefined for an empty stack (0.331345ms)
▶ a new stack (5.915444ms)

ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 128.251053

2.7.测试报告

你可以通过 --test-reporter 来指定测试报告打印规范,可以传递多个报告器以及它们的文件目的地,还可以编写自己的测试报告器。

支持以下三种内置报告器:

  • tap:报告以 TAP 格式输出测试结果。
  • spec:报告以人类可读格式输出测试结果,默认值为 spec。
  • dot:报告以紧凑格式输出测试结果,其中每个通过的测试用 .表示,每个未通过的测试用 X 表示。
➜ node --test --test-reporter spec
▶ a new stack
✔ is empty (0.961177ms)
✔ is not empty after push (0.504095ms)
✔ pop returns undefined for an empty stack (0.290552ms)
▶ a new stack (5.598469ms)

ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 109.803427

➜ node --test --test-reporter tap
TAP version 13
# Subtest: a new stack
# Subtest: is empty
ok 1 - is empty
---
duration_ms: 0.748715
...
# Subtest: is not empty after push
ok 2 - is not empty after push
---
duration_ms: 0.743287
...
# Subtest: pop returns undefined for an empty stack
ok 3 - pop returns undefined for an empty stack
---
duration_ms: 0.353195
...
1..3
ok 1 - a new stack
---
duration_ms: 4.628343
type: 'suite'
...
1..1
# tests 3
# suites 1
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 95.359522
➜ node --test --test-reporter dot
....

以上简要阐述了使用 Node.js 测试运行器的基础用法。上面所写的测试用例都是无依赖测试,只需要使用 Node 20.x版本,就可以在您的 Node.js 应用程序中使用。这并不是测试运行器提供的全部功能。它还提供了内置的模拟功能、实验性的观察模式和测试覆盖范围收集。下面

3.总结

对于小型项目,Node.js 内置的测试运行器和断言模块提供了编写测试套件所需的一切。确保代码经过良好测试是编写整洁代码的重要一环,而平台内置的这些工具让我们从一开始就能更轻松地设置和编写测试。大型项目,可根据团队情况和场景选择更适合自己的测试框架。

现在各大厂已在逐步推行“测试左移”,旨在减少黑盒测试成本,提高研效效率和线上环境的稳定性。从长远看,自动化测试是个趋势,会逐渐替换手工测试,随着端到端、集成测试、单元测试等框架的日渐成熟,必然会对现存的测试岗位带来较大的冲击,测试同学需要提前做好职业规划。

还是很期待看到 Node.js 内置运行器的进一步发展。最近的 Bun 1.0 发布后,圈子里异常火爆,它是一个一体化的 JavaScript 运行时和工具包,显著降低了开发成本,也是受开发者热捧的原因之一。

最后,如果你即将开始一个新项目,建议你试试内置的测试运行器,看看它对你有什么帮助。欢迎关注公众号留言并一起讨论。

猜你想看

继续滑动看下一个

Node.js 原生 Test Runner 综合指南

FEDLAB FED实验室
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存