实际进行集成测试的 Node.js 指南

已发表: 2022-03-11

集成测试不应该是可怕的。 它们是对您的应用程序进行全面测试的重要组成部分。

在谈论测试时,我们通常会想到单元测试,我们在其中单独测试一小块代码。 但是,您的应用程序比那一小段代码要大,而且您的应用程序几乎没有任何部分是孤立地工作的。 这就是集成测试证明其重要性的地方。 集成测试弥补了单元测试的不足,它们弥合了单元测试和端到端测试之间的差距。

你知道你需要编写集成测试,那你为什么不这样做呢?
鸣叫

在本文中,您将学习如何使用基于 API 的应用程序中的示例编写可读且可组合的集成测试。

虽然我们将在本文中的所有代码示例中使用 JavaScript/Node.js,但讨论的大多数想法都可以轻松地适应任何平台上的集成测试。

单元测试与集成测试:两者都需要

单元测试专注于一个特定的代码单元。 通常,这是特定方法或更大组件的功能。

这些测试是独立完成的,所有外部依赖项通常都被存根或模拟。

换句话说,依赖关系被预先编程的行为所取代,确保测试的结果仅由被测试单元的正确性决定。

您可以在此处了解有关单元测试的更多信息。

单元测试用于维护具有良好设计的高质量代码。 它们还使我们能够轻松覆盖极端情况。

然而,缺点是单元测试不能涵盖组件之间的交互。 这就是集成测试变得有用的地方。

集成测试

如果单元测试是通过单独测试最小的代码单元来定义的,那么集成测试正好相反。

集成测试用于在交互中测试多个更大的单元(组件),有时甚至可以跨越多个系统。

集成测试的目的是发现各种组件之间的连接和依赖关系中的错误,例如:

  • 传递无效或错误排序的参数
  • 损坏的数据库架构
  • 缓存集成无效
  • 业务逻辑缺陷或数据流错误(因为现在从更广泛的角度进行测试)。

如果我们正在测试的组件没有任何复杂的逻辑(例如具有最小圈复杂度的组件),那么集成测试将比单元测试重要得多。

在这种情况下,单元测试将主要用于强制执行良好的代码设计。

虽然单元测试有助于确保正确编写函数,但集成测试有助于确保系统作为一个整体正常工作。 因此,单元测试和集成测试都有各自的互补目的,并且对于全面的测试方法都是必不可少的。

单元测试和集成测试就像一枚硬币的两面。 没有两者,硬币无效。

因此,在您完成集成和单元测试之前,测试是不完整的。

设置集成测试套件

虽然为单元测试设置测试套件非常简单,但为集成测试设置测试套件通常更具挑战性。

例如,集成测试中的组件可能具有项目外部的依赖项,如数据库、文件系统、电子邮件提供商、外部支付服务等。

有时,集成测试需要使用这些外部服务和组件,有时它们可​​以被存根。

当需要它们时,可能会带来一些挑战。

  • 脆弱的测试执行:外部服务可能不可用、返回无效响应或处于无效状态。 在某些情况下,这可能会导致误报,有时可能会导致误报。
  • 执行缓慢:准备和连接到外部服务可能很慢。 通常,测试作为 CI 的一部分在外部服务器上运行。
  • 复杂的测试设置:外部服务需要处于测试所需的状态。 例如,数据库应该预先加载必要的测试数据等。

编写集成测试时要遵循的指导

集成测试没有像单元测试那样的严格规则。 尽管如此,在编写集成测试时还是需要遵循一些通用的方向。

可重复测试

测试顺序或依赖项不应改变测试结果。 多次运行相同的测试应该总是返回相同的结果。 如果测试使用 Internet 连接到第三方服务,这可能很难实现。 然而,这个问题可以通过存根和模拟来解决。

对于您有更多控制权的外部依赖项,在集成测试之前和之后设置步骤将有助于确保测试始终从相同的状态开始运行。

测试相关操作

为了测试所有可能的情况,单元测试是一个更好的选择。

集成测试更侧重于模块之间的连接,因此测试快乐的场景通常是要走的路,因为它将涵盖模块之间的重要连接。

可理解的测试和断言

一个快速的测试视图应该告诉读者正在测试什么,环境是如何设置的,什么是存根的,何时执行测试,以及断言了什么。 断言应该很简单,并使用助手来更好地比较和记录。

简单的测试设置

让测试进入初始状态应该尽可能简单易懂。

避免测试第三方代码

虽然可以在测试中使用第三方服务,但没有必要对其进行测试。 如果你不信任它们,你可能不应该使用它们。

让生产代码没有测试代码

生产代码应该简洁明了。 将测试代码与生产代码混合将导致两个不可连接的域耦合在一起。

相关日志

如果没有良好的日志记录,失败的测试并不是很有价值。

当测试通过时,不需要额外的日志记录。 但是当它们失败时,广泛的日志记录是至关重要的。

日志记录应包含所有数据库查询、API 请求和响应,以及断言内容的完整比较。 这可以显着方便调试。

好的测试看起来清晰易懂

遵循此处指南的简单测试可能如下所示:

 const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));

上面的代码正在测试一个 API ( GET /v1/admin/recipes ),它期望它返回一个保存的食谱数组作为响应。

您可以看到,尽管测试可能很简单,但它依赖于许多实用程序。 这对于任何好的集成测试套件都很常见。

辅助组件使编写可理解的集成测试变得容易。

让我们回顾一下集成测试需要哪些组件。

辅助组件

一个全面的测试套件包含一些基本要素,包括:流控制、测试框架、数据库处理程序以及连接到后端 API 的方法。

流量控制

JavaScript 测试中最大的挑战之一是异步流程。

回调可能会对代码造成严重破坏,而 Promise 是不够的。 这就是流量助手变得有用的地方。

在等待 async/await 被完全支持时,可以使用具有类似行为的库。 目标是编写可读的、有表现力的、健壮的代码,并有可能具有异步流。

Co 使代码能够以一种很好的方式编写,同时保持非阻塞。 这是通过定义一个协同生成器函数然后产生结果来完成的。

另一种解决方案是使用 Bluebird。 Bluebird 是一个 Promise 库,具有非常有用的功能,例如处理数组、错误、时间等。

Co 和 Bluebird 协程的行为类似于 ES7 中的 async/await(在继续之前等待解析),唯一的区别是它总是返回一个 Promise,这对于处理错误很有用。

测试框架

选择测试框架取决于个人喜好。 我的偏好是一个易于使用、没有副作用、并且输出易于阅读和管道传输的框架。

JavaScript 中有大量的测试框架。 在我们的示例中,我们使用的是磁带。 在我看来,Tape 不仅满足了这些要求,而且比 Mocha 或 Jasmin 等其他测试框架更干净、更简单。

磁带基于测试任何协议 (TAP)。

TAP 具有适用于大多数编程语言的变体。

Tape 将测试作为输入,运行它们,然后将结果作为 TAP 输出。 然后可以将 TAP 结果通过管道传输到测试报告器,或者以原始格式输出到控制台。 磁带从命令行运行。

Tape 有一些很好的特性,比如在运行整个测试套件之前定义一个要加载的模块,提供一个小而简单的断言库,以及定义应该在测试中调用的断言数量。 使用模块进行预加载可以简化测试环境的准备工作,并删除任何不必要的代码。

工厂图书馆

工厂库允许您用更灵活的方式替换静态夹具文件来生成测试数据。 这样的库允许您定义模型并为这些模型创建实体,而无需编写凌乱、复杂的代码。

JavaScript 有 factory_girl ——一个库,灵感来自一个名称相似的 gem,它最初是为 Ruby on Rails 开发的。

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');

首先,必须在 factory_girl 中定义一个新模型。

它使用名称、项目中的模型和从中生成新实例的对象来指定。

或者,代替定义生成新实例的对象,可以提供一个返回对象或承诺的函数。

在创建模型的新实例时,我们可以:

  • 覆盖新生成的实例中的任何值
  • 将附加值传递给构建函数选项

让我们看一个例子。

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}

连接到 API

启动一个成熟的 HTTP 服务器并发出一个实际的 HTTP 请求,然后在几秒钟后将其关闭——尤其是在执行多个测试时——完全是低效的,并且可能导致集成测试花费的时间比必要的长得多。

SuperTest 是一个 JavaScript 库,用于在不创建新的活动服务器的情况下调用 API。 它基于 SuperAgent,一个用于创建 TCP 请求的库。 使用此库,无需创建新的 TCP 连接。 API 几乎立即被调用。

SuperTest 支持承诺,是承诺的超级测试。 当这样的请求返回一个 Promise 时,它​​可以让您避免多个嵌套的回调函数,从而更容易处理流程。

 const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));

SuperTest 是为 Express.js 框架制作的,但只需稍作改动,它也可以与其他框架一起使用。

其他实用程序

在某些情况下,需要在我们的代码中模拟一些依赖关系,使用间谍测试围绕函数的逻辑,或者在某些地方使用存根。 这是其中一些实用程序包派上用场的地方。

SinonJS 是一个很棒的库,它支持用于测试的间谍、存根和模拟。 它还支持其他有用的测试功能,例如弯曲时间、测试沙箱和扩展断言,以及假服务器和请求。

在某些情况下,需要在我们的代码中模拟一些依赖项。 对我们想要模拟的服务的引用被系统的其他部分使用。

为了解决这个问题,我们可以使用依赖注入,或者,如果这不是一个选项,我们可以使用像 Mockery 这样的模拟服务。

Mockery 有助于模拟具有外部依赖关系的代码。 为了正确使用它,应该在加载测试或代码之前调用 Mockery。

 const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);

有了这个新的参考(在这个例子中, mockingStripe ),在我们的测试中更容易模拟服务。

 const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));

在 Sinon 库的帮助下,很容易模拟。 这里唯一的问题是这个存根会传播到其他测试。 要对其进行沙箱处理,可以使用 sinon 沙箱。 有了它,以后的测试可以使系统恢复到初始状态。

 const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();

需要其他组件来实现以下功能:

  • 清空数据库(可以通过一个层次结构预构建查询来完成)
  • 将其设置为工作状态(sequelize-fixtures)
  • 模拟 TCP 请求到 3rd 方服务 (nock)
  • 使用更丰富的断言 (chai)
  • 保存来自第三方的回复(易于修复)

不那么简单的测试

抽象和可扩展性是构建有效集成测试套件的关键要素。 将焦点从测试核心(准备其数据、操作和断言)中移开的所有内容都应该分组并抽象为实用函数。

尽管这里没有正确或错误的路径,因为一切都取决于项目及其需求,但任何好的集成测试套件仍然有一些关键品质。

以下代码显示了如何测试创建配方并发送电子邮件作为副作用的 API。

它会存根外部电子邮件提供商,以便您可以测试是否会在没有实际发送的情况下发送电子邮件。 该测试还验证 API 是否使用适当的状态代码进行响应。

 const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));

上面的测试是可重复的,因为它每次都从干净的环境开始。

它有一个简单的设置过程,与设置相关的所有内容都合并到basicEnv.test函数中。

它只测试一个动作——一个 API。 它通过简单的断言语句清楚地说明了测试的期望。 此外,测试不涉及通过存根/模拟的第三方代码。

开始编写集成测试

在将新代码推送到生产环境时,开发人员(以及所有其他项目参与者)希望确保新功能能够正常工作而旧功能不会中断。

如果没有测试,这是很难实现的,如果做得不好会导致挫败感、项目疲劳,并最终导致项目失败。

集成测试,结合单元测试,是第一道防线。

仅使用两者之一是不够的,并且会为未发现的错误留下大量空间。 始终使用这两者将使新的提交变得健壮,并为所有项目参与者带来信心并激发信任。