本文概述
集成测试不是可怕的事情。它们是对应用程序进行全面测试的重要组成部分。
在谈论测试时, 我们通常会想到单元测试, 其中我们独立地测试一小段代码。但是, 你的应用程序比一小段代码要大, 并且几乎没有应用程序的任何部分独立运行。这就是集成测试证明其重要性的地方。集成测试会在单元测试不足的地方进行补充, 并弥合单元测试和端到端测试之间的差距。
你知道需要编写集成测试, 所以为什么不这样做呢?
鸣叫
在本文中, 你将学习如何编写基于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返回一个保存的食谱数组作为响应。
你可以看到, 测试虽然非常简单, 但它依赖于许多实用程序。这对于任何好的集成测试套件都是很常见的。
Helper组件使编写易于理解的集成测试变得容易。
让我们回顾一下集成测试所需的组件。
辅助组件
全面的测试套件具有一些基本要素, 包括:流控制, 测试框架, 数据库处理程序以及连接到后端API的方法。
流量控制
JavaScript测试中的最大挑战之一是异步流程。
回调可能会对代码造成严重破坏, 而且承诺还远远不够。这是流程助手变得有用的地方。
在等待完全支持异步/等待时, 可以使用行为类似的库。目的是编写具有异步流的可读性, 表达性和健壮性的代码。
Co使代码能够以一种不错的方式编写, 同时又保持其非阻塞性。这是通过定义协发生器函数然后产生结果来完成的。
另一个解决方案是使用Bluebird。 Bluebird是一个Promise库, 它具有非常有用的功能, 例如处理数组, 错误, 时间等。
Co和Bluebird协程的行为类似于ES7中的异步/等待(等待解析, 然后继续), 唯一的区别是它将始终返回一个promise, 这对于处理错误很有用。
测试框架
选择测试框架仅取决于个人喜好。我更喜欢一个易于使用, 无副作用且易于读取和传递输出的框架。
JavaScript有各种各样的测试框架。在我们的示例中, 我们正在使用Tape。我认为, Tape不仅可以满足这些要求, 而且比其他测试框架(如Mocha或Jasmin)更干净, 更简单。
磁带基于”测试任何协议”(TAP)。
TAP对于大多数编程语言都有变体。
磁带将测试作为输入, 运行它们, 然后将结果作为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请求模拟到第三方服务(模拟)
- 使用更丰富的断言(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。并通过简单的assert语句清楚地说明了测试的期望。另外, 测试不会通过存根/模拟来涉及第三方代码。
开始编写集成测试
在将新代码投入生产时, 开发人员(和所有其他项目参与者)希望确保新功能可以正常使用, 而旧功能不会损坏。
如果不进行测试, 很难做到这一点, 如果做得不好, 可能会导致挫败感, 项目疲劳甚至最终导致项目失败。
集成测试与单元测试相结合是第一道防线。
仅使用两者之一是不够的, 并且会为发现的错误留出大量空间。始终利用两者都将使新的提交变得强大, 并提供信心并激发对所有项目参与者的信任。
评论前必须登录!
注册