Skip to main content

如何对前端代码实施测试

相关问题

  • 你是如何看待软件测试的?
  • 你在工作中是如何实施测试的?
  • 对前端代码进行测试,有哪些测试方法?
  • 说说你对测试驱动开发的理解?

回答关键点

Unit Tests E2E Tests 测试覆盖率 TDD BDD

测试是保障代码质量的重要手段,可以验证我们代码的正确性;测试可以增强我们在修改代码时的信心,保证代码重构的正确性;编写测试还有助于理解业务功能,避免在编写代码时做出过度的设计;对于开发人员来说,测试用例还是最好的开发文档。测试的分类方法有很多种,一般按测试阶段可以分为单元测试(Unit Tests)、集成测试(Integration Tests)、E2E 测试(端到端测试,End-to-End Tests)来保障产品关键路径的可靠性。

一般来说,测试代码由大量的单元测试,适中的集成测试,以及少量的 E2E 测试构成。

测试驱动开发(TDD)是一种通过编写测试来指导软件的开发过程,构建软件的技术。

知识点深入

测试的种类

从不同的角度对待测试,会有不同的分类方法,例如白盒测试、回归测试、灰度测试、冒烟测试等。以下主要关注测试阶段。按测试的不同目标,在代码中进行以下三种自动化测试:

这三种测试构成测试金字塔,从 E2E 测试到单元测试,集成度越来越低、隔离度越来越高,运行测试所需要的时间也越来越短。 image

单元测试

是指对软件中独立单元(通常是一个函数、一个模块或一组紧密关联的功能集合)进行测试的方法。单元测试至少应该对模块暴露的公共接口进行测试,同时不应与代码的具体实现耦合得太紧密(如 Element 的 classname)。单元测试中经常会用到被称为 Mocks 或 Stubs 的 fake 实现,这可以使测试免于影响真实的系统,或者让测试运行得更快。

关于测试的 mock,一般有两种不同的意见:一派认为对当前测试点关注之外的东西都可以进行 mock,以实现完美的隔离,来达到避免副作用和进行复杂的测试环境设置的目的;另一派认为要尽量少的在测试中使用 mock,因为 mock 可能与真实的接口存在不一致的地方,而这很难察觉到。在实际的工作中,都会综合考虑这两派的意见,合理谨慎的运用 mock。通常测试覆盖率达到 100% 并不意味着软件就是绝对可靠的。

集成测试

一个完整的软件通常需要很多子系统配合工作。比如对后端来说,包含数据库、文件系统及其他网络调用;对前端来说,包含 UI 组件,数据仓库,网络请求等。正如单元测试难以精确界定一个单元的范围一样,集成测试在边界的确定中比单元测试更加模糊。集成测试一般是指对系统和外部功能的集成整体进行的测试。

另一方面,集成测试的含义通常比单元测试更模糊,如何确定测试的边界往往随着项目的具体情况而各有不同。集成测试中通常有着和单元测试重复的部分。有一种观点是认为可以用契约测试(Contract Tests)代替部分集成测试。

在现代化的组织架构中,随着软件的规模越来越大,开发部门也会分成各个规模较小的团队,各自负责相对独立的部分,构建高内聚、低耦合的服务。不同的服务之间通常是通过接口互相通信的,比如通过 HTTP 进行 REST 通信,使用 gRPC 来进行 RPC 通信,或者使用消息队列来进行通信。这时,可以考虑使用契约测试,来对各个子系统提供的接口进行测试。

E2E 测试

测试金字塔的最顶端一般是 E2E 测试,在进行测试时,一般有测试人员手动进行的测试,也有自动化的工具进行的测试。

E2E 测试中的一条用例会覆盖一条完整的用户操作流程。对于 web 程序来说,我们会测试用户的输入是否触发了正确的动作、数据是否呈现给了用户、UI 状态是否按照预期改变等等。

Web 应用程序的 E2E 测试通常是使用 WebDriver 来驱动实际的浏览器运行来进行测试的,这通常意味着 E2E 测试会很慢。所以我们在功能点能够被前面两种测试覆盖的时候,尽量采用它们来进行测试。

如何编写测试

最理想的状态是践行测试驱动开发,采用先红后绿的流程开发软件。这比写完功能代码后再补充测试要好,因为后补测试用例是在功能代码已经完成的情况下进行的,这也就是所谓的白盒测试。

白盒测试可能面临某些思维盲点(例如一旦从一堆不规则的图形中找出了五角星,后面再次看到时就很难再忽略它了),可能会导致测试覆盖的不够全面。白盒测试也可能过多的测试边缘情况,而忽略了业务主流程。 image

图片来源 像用户一样测试:打破知识诅咒

TDD 有四个关键点:

  1. 思考(Think):对业务需求进行拆解,将需求分解为有上下文、行为和预期结果的一个或多个任务列表,并保证列表中的每个任务是简单的、能快速实现的。
  2. 红(Red bar):从上一步的任务列表中挑选任务,为该任务快速的编写测试代码,然后不断循环的完成所有任务到测试用例的转换。此时,运行测试代码时的断言是会失败的。
  3. 绿(Green bar):以最快的速度让测试变绿,这通常意味着不需要考虑复杂的设计模式,不做过度设计,不写多余的代码,只需要符合一些简单的设计原则就行了。
  4. 重构(Refactor):在上面所有的测试都通过以后,现在可以对代码开始重构(识别代码中的坏味道(Code smell)、应用更健壮的设计模式等),而不必担心破坏之前的功能。
  5. 当准备好添加新的功能时,重新开始上面的循环。不断添加小的增量是实践 TDD 的关键。

测试的结构

对于所有的测试代码,一般都会遵循以下的结构。

  1. 设置测试数据。
  2. 调用测试方法。
  3. 断言期望的结果已经返回。

这个结构通常被记为 "Arrange, Act, Assert" 的 3A 短语。另外有一个来自 BDD(行为驱动开发)的一组短语是 "Given, When, Then"。

测试工具

单元测试工具

测试框架:常用的测试框架有 Jest,Mocha,Jasmine,AVA 等,大部分测试框架都提供了类似的功能,一般包括以下这些方面:

  • 测试块的组织,比如 describe 和 it/test 语法。
  • 每个 describe 块的 before[All],after[All],beforeEach,afterEach 钩子。
  • 全局的 setup 和 teardown 设置。
  • 匹配语法,各个框架一般拥有相似的接口,而这个风格一般继承自 Chai 这个框架。
  • Mock 功能的支持,对定时器的 mock、对函数的 mock、对模块的 mock。

被 Chai 发扬光大的三种匹配语法:

// Assert 经典语法,提供了对被测试对象的各个维度的断言语法。
assert.typeOf(foo, "string");
assert.equal(foo, "bar");
assert.lengthOf(foo, 3);
assert.property(tea, "flavors");
assert.lengthOf(tea.flavors, 3);

// 下面两种都是 BDD 风格的断言方式,它们都使用了更接近自然表达的语法来进行匹配。
// 在 node.js 和现代浏览器中,它们几乎没有区别。
// Should 采用了修改 Object 原型的方式实现到链式语法,这可能在古老的 IE 浏览器或某些嵌入式 JS 运行时中不能正常工作。

// Expect
expect(foo).to.be.a("string");
expect(foo).to.equal("bar");
expect(foo).to.have.lengthOf(3);
expect(tea).to.have.property("flavors").with.lengthOf(3);

// Should
foo.should.be.a("string");
foo.should.equal("bar");
foo.should.have.lengthOf(3);
tea.should.have.property("flavors").with.lengthOf(3);

其他拥有的测试工具:

  • 针对具体 UI 框架的测试插件,如针对 React 的 @testing-library/reactenzymereact-test-renderer 等。
  • 模拟 DOM 环境的 jsdom
  • 践行 BDD 的代表 Cucumber.js
  • 收集测试覆盖率的辅助工具 istanbul,或使用 v8 引擎内置的覆盖率功能的 c8

测试覆盖率收集原理:

istanbul.js 提供了名为 nyc 的工具,执行 nyc instrument <input> <output> 即可完成对代码的插桩。在代码实际运行时,插桩代码会统计源代码各部分的运行次数,来统计覆盖率。

例子:

greeting.js
function greeting(name = "HZFE") {
if (DEBUG) {
name = `${name} (DEBUG)`;
}

const foo = DEBUG ? "(DEBUG) foo" : "foo";

for (const i = 0; i < name.length; i++) {
name += "!";
}

return `Hello ${name}`;
}

经过 nyc instrument 后,大致代码如下:

output.js
function cov_g4lly5ec() {
// ...
var coverageData = {
path: "...",
statementMap: {
0: { start: { line: 2, column: 2 }, end: { line: 4, column: 3 } },
// ...
},
fnMap: {
0: {
name: "greeting",
decl: { start: { line: 1, column: 9 }, end: { line: 1, column: 17 } },
loc: { start: { line: 1, column: 33 }, end: { line: 13, column: 1 } },
line: 1,
},
},
branchMap: {
0: {
loc: { start: { line: 1, column: 18 }, end: { line: 1, column: 31 } },
type: "default-arg",
locations: [
{ start: { line: 1, column: 25 }, end: { line: 1, column: 31 } },
],
line: 1,
},
// ...
},
s: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 },
f: { 0: 0 },
b: { 0: [0], 1: [0, 0], 2: [0, 0] },
// ...
};
// ...
return actualCoverage;
}
cov_g4lly5ec();
function greeting(name = (cov_g4lly5ec().b[0][0]++, "HZFE")) {
cov_g4lly5ec().f[0]++;
cov_g4lly5ec().s[0]++;
if (DEBUG) {
cov_g4lly5ec().b[1][0]++;
cov_g4lly5ec().s[1]++;
name = `${name} (DEBUG)`;
} else {
cov_g4lly5ec().b[1][1]++;
}
const foo =
(cov_g4lly5ec().s[2]++,
DEBUG
? (cov_g4lly5ec().b[2][0]++, "(DEBUG) foo")
: (cov_g4lly5ec().b[2][1]++, "foo"));
cov_g4lly5ec().s[3]++;
for (const i = (cov_g4lly5ec().s[4]++, 0); i < name.length; i++) {
cov_g4lly5ec().s[5]++;
name += "!";
}
cov_g4lly5ec().s[6]++;
return `Hello ${name}`;
}

istanbul 在解析源代码时,会在函数体、默认参数、分支语句等各处位置插入记录执行次数的代码,在三元运算符等特殊位置会利用 JS 逗号运算符的特性插入统计代码。

例,在cov_g4lly5ec().b[1][0]++; 之中的 b 代表 branch(分支),1 代表第一个分支语句,1 后面紧跟的 0 代表分支的第一个叉路。依此类推,这些代码记录了 f(function),s(statement),b(branch) 的执行次数。后面的索引最终会解析到 statementMapfnMapbranchMap 中的对应项,映射到源代码中的具体位置。这样在执行完测试用例中,就能够知道源代码中每一处语法结构被运行的情况。

E2E 测试

常见工具有 Pupeteer、Nightwatch、TestCafe、Selenium、Cypress、Playwright 等。它们都是通过控制浏览器执行代码来模拟实际用户的操作,来验证 UI 的变化符合预期。E2E 测试的目的是为了尽量测试软件在真实环境下的表现,一般很少使用 mock。 基于测试在真实的浏览器下运行,和测试环境网络不稳定两个主要因素,E2E 测试通常要执行很长的时间,并且会较频繁的面临失败。在实际的持续集成环境中,一般会让 E2E 测试重试 2 到 3 次来尽可能的排除环境因素导致的失败。

在这些常用的工具中,目前使用比较广泛的三个是 Pupeteer,Cypress,Playwright。它们三个有比较突出的特点:

  • Pupeteer:Google 官方提供的测试工具,使用 Chrome DevTools Protocol 与 Chromium 系列的浏览器进行交互。所提供的 API 是比较底层的基本操作,通常需要自己封装常用的操作,但在具体的使用中相对灵活,限制较少。
  • Cypress: 高度集成的测试工具,自动等待异步操作,降低编码门槛。提供 Test Runner 可视化运行测试,记录测试步骤中的具体细节,可以查看测试代码中每一步所对应的 DOM 快照,便于 debug。基于 iframe 运行,测试代码和被测试应用处于同一运行环境,可以提高测试的可靠性。目前的限制是不支持多窗口,以及 iframe 在安全策略上的小部分无法规避的限制。
  • Playwright:微软开发的测试工具,支持除 Chromium 之外的 Firefox 和 Safari,拥有 Trace View 可以提供类似 Cypress 的强大 debug 体验。支持除了 JS/TS 以外的语言如 Python、.NET、Java,适用范围更广。

集成测试

一般情况下,集成测试所需使用的工具都涵盖在单元测试和 E2E 测试使用的工具之中了。

进行测试的最佳实践

  1. 测试应该足够快。只有测试能够快速运行得到反馈,测试人员才会乐意采用 TDD 的方式进行开发。反例是 E2E 测试,通常开发人员不会想要在本地频繁的运行 E2E 测试,因为这会浪费他们很多时间。使测试运行足够快,合理的配置测试框架,只运行改动相关的测试。
  2. 测试应该足够简单。如果测试代码写的非常复杂,也许会有疑问:“如何保证测试代码是正确的呢?”,那就可能需要对测试代码进行测试,所以在测试代码中,应该保证使用简单的代码,不要使用复杂、抽象度高的代码。对于确实需要使用复杂工具的场景,可以尝试使用久经考验的工具库。或者自己抽象一部分测试辅助工具代码,并对其进行测试。
  3. 测试应该具备良好的可读性。和上一条类似,保持简单的同时,也要保证良好的可读性。良好的可读性可以避免开发人员误解测试用例的意图,并可以最大限度发挥测试用例作为最佳文档的功能。虽然可读性是比较主观的感受,但也可以通过遵循下面的规则进行一定程度上的量化。
    • 使用 "Arrange, Act, Assert" 或 "Given, When, Then" 来组织测试代码。
    • 每个测试块中专注于一项功能的测试,不要将太多断言混合在一起。
    • 避免使用魔术数字和字符串,使用含义更清晰的常量取代。
  4. 测试代码中不要重复实现源代码中的逻辑。例如,用于与实际代码返回的 Received 对比的 Expected 应该是一个简单的列举出来的字面量,而不是通过和源代码类似的逻辑动态生成的结果。
  5. 测试必须是稳定的。测试用例的成功或失败只取决于被测试的代码,测试用例不因其他用例、环境因素、外部依赖的变化而改变结果。
  6. 不要将测试与实现细节耦合。提高测试用例稳定性,避免因为代码中微小的改动而导致测试用例失败。如,使用 contain 代替 equal 检查内容;使用 getByRole 代替 querySelector。

参考资料

Loading script...