我们对可测试js代码的学习已经有了一些理解,也分析了一些关于测试替身的问题,比如什么时候使用spies?什么时候使用stubs,什么时候使用mocks。
今天我们用一个测试框架sinonjs来做具体的说明。
简介
测试使用了Ajax、网络、超时、数据库或其他依赖项的代码可能很困难。例如,如果您使用 Ajax 或网络,您需要有一个服务器来响应您的请求。使用数据库,您需要使用测试数据设置测试数据库。
所有这一切都意味着编写和运行测试更加困难,因为您需要做额外的工作来准备和设置一个测试可以成功运行的环境。
幸运的是,我们可以使用 Sinon.js 来避免所有的麻烦。我们可以利用它的特性将上述案例简化为几行代码。
然而,开始使用Sinon可能会很棘手。你会以所谓的spies、stubs和mocks的形式获得很多功能,但很难选择何时使用什么。他们也有一些陷阱,所以你需要知道你在做什么以避免问题。
在本文中,我们将向您展示spies、stubs和mocks之间的区别、何时以及如何使用它们,并为您提供一组最佳实践来帮助您避免常见的陷阱。
样例函数
为了更容易理解我们在说什么,下面是一个简单的函数来说明示例。
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
该函数有两个参数——一个带有我们想要保存的数据的对象和一个回调函数。我们将 info 对象中的数据放入 user 变量中,并将其保存到数据库中。就本示例而言,save 的作用无关紧要——它可以发送 Ajax 请求,或者,如果这是 Node.js 代码,也许它会直接与数据库对话,但细节并不重要。想象一下它做了某种数据保存操作。
Spies, Stubs and Mocks
spies、stubs和mocks一起被称为测试替身。类似于特技替身如何在电影中完成危险的工作,我们使用测试替身来代替麻烦制造者并使测试更容易编写。
什么时候需要测试替身?
为了更好地理解何时使用测试替身,我们需要了解我们可以拥有的两种不同类型的函数。我们可以将函数分为两类:
- 无副作用的功能(纯函数)
- 以及有副作用的功能(非纯函数)
没有副作用的函数很简单:这样的函数的结果只取决于它的参数——在给定相同参数的情况下,函数总是返回相同的值。
具有副作用的函数可以定义为依赖于外部事物的函数,例如某个对象的状态、当前时间、对数据库的调用或其他一些保持某种状态的机制。除了参数之外,这种函数的结果还可能受到多种因素的影响。
如果回顾示例函数,我们会在其中调用两个函数——toLowerCase 和 Database.save。前者没有副作用——toLowerCase 的结果只取决于输入字符串的值。然而,后者有一个副作用——如前所述,它执行某种保存操作,因此 Database.save 的结果也会受到该操作的影响。
如果我们想测试 setupNewUser,我们可能需要在 Database.save 上使用 test-double,因为它有副作用。换句话说,我们可以说当函数有副作用时我们需要测试替身。
除了具有副作用的函数之外,我们有时可能需要测试替身与在我们的测试中引起问题的函数。一个常见的情况是当一个函数执行计算或其他一些非常慢的操作时,这会使我们的测试变慢。然而,我们主要需要测试替身来处理具有副作用的函数。
何时使用Spies
顾名思义,spies用于获取有关函数调用的信息。例如,spies可以告诉我们一个函数被调用了多少次,每次调用有哪些参数,返回了哪些值,抛出了哪些错误等等。
因此,只要测试的目标是验证发生了什么,spies就是一个不错的选择。结合 Sinon 的断言,我们可以使用一个简单的 spy 来检查许多不同的结果。
spies最常见的场景包括
- 检查函数被调用的次数
- 检查传递给函数的参数
我们可以使用 sinon.assert.callCount
、sinon.assert.callOnce
、sinon.assert.notCalled
等来检查一个函数被调用了多少次。例如,以下是验证是否调用了 save 函数的方法:
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
//验证spy函数调用了多少次
it('should call save once', function() {
var save = sinon.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
save.restore();
sinon.assert.calledOnce(save);
});
});
我们可以使用 sinon.assert.calledWith
或通过使用 spy.lastCall
或 spy.getCall()
直接访问调用来检查传递给函数的参数。例如,如果我们想验证上述保存函数接收到正确的参数,我们将使用以下规范:
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
it('should pass object with correct values to save', function() {
var save = sinon.spy(Database, 'save');
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
setupNewUser(info, function() { });
save.restore();
sinon.assert.calledWith(save, expectedUser);
});
});
不过,这些并不是您可以通过spies检查的唯一内容——Sinon 提供了许多其他断言,您可以使用这些断言来检查各种不同的内容。相同的断言也可以用于存根。
如果您spy某个函数,则该函数的行为不会受到影响。如果你想改变一个函数的行为方式,你需要一个stub。
何时使用Stubs
stubs就像spies,只是它们替换了目标函数。它们还可以包含自定义行为,例如返回值或抛出异常。他们甚至可以自动调用作为参数提供的任何回调函数。
stubs有一些常见用途:
- 您可以使用它们来替换有问题的代码片段
- 您可以使用它们来触发不会触发的代码路径——例如错误处理
- 您可以使用它们来帮助更轻松地测试异步代码
Stubs可用于替换有问题的代码,即使编写测试变得困难的代码。这通常是由外部因素引起的——网络连接、数据库或其他一些非 JavaScript 系统。这些问题是它们通常需要手动设置。例如,我们需要在运行测试之前用测试数据填充数据库,这使得运行和编写它们变得更加复杂。
如果我们将有问题的代码片段取而代之,我们可以完全避免这些问题。我们之前的示例使用了 Database.save,如果我们在运行测试之前没有设置数据库,这可能会出现问题。因此,在其上使用stubs而不是spies可能是个好主意。
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
it('should pass object with correct values to save', function() {
var save = sinon.stub(Database, 'save');
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
setupNewUser(info, function() { });
save.restore();
sinon.assert.calledWith(save, expectedUser);
});
});
通过用stubs替换与数据库相关的函数,我们不再需要一个实际的数据库来进行测试。几乎任何涉及难以测试的代码的情况都可以使用类似的方法。
Stubs也可用于触发不同的代码路径。果我们正在测试的代码调用了另一个函数,我们有时需要测试它在异常情况下的行为——最常见的是如果出现错误。我们可以利用存根从代码中触发错误:
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
it('should pass the error into the callback if save fails', function() {
var expectedError = new Error('oops');
var save = sinon.stub(Database, 'save');
save.throws(expectedError);
var callback = sinon.spy();
setupNewUser({ name: 'foo' }, callback);
save.restore();
sinon.assert.calledWith(callback, expectedError);
});
});
第三,Stubs可用于简化测试异步代码。如果我们stub一个异步函数,我们可以强制它立即调用回调,使测试同步并消除异步测试处理的需要。
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
it('should pass the database result into the callback', function() {
var expectedResult = { success: true };
var save = sinon.stub(Database, 'save');
save.yields(null, expectedResult);
var callback = sinon.spy();
setupNewUser({ name: 'foo' }, callback);
save.restore();
sinon.assert.calledWith(callback, null, expectedResult);
});
});
Stubs是高度可配置的,并且可以做的远不止这些,但大多数都遵循这些基本思想。
何时使用Mocks
使用 mock 时你应该小心——当 mock 可以做任何事情时很容易忽略 spies 和 stub,但是 mock 也很容易让你的测试过于具体,这导致脆弱的测试很容易崩溃。易碎测试是在更改代码时很容易意外中断的测试。
Mocks 应该主要在您使用stubs时使用,但需要在其上验证多个更具体的行为。
例如,下面是我们如何使用模拟来验证更具体的数据库保存场景:
require("@fatso83/mini-mocha").install();
const sinon = require("sinon");
describe('test spy example', function(){
const Database = {
save: function(){
}
}
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
it('should pass object with correct values to save only once', function() {
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
var database = sinon.mock(Database);
database.expects('save').once().withArgs(expectedUser);
setupNewUser(info, function() { });
database.verify();
database.restore();
});
});
请注意,通过mocks,我们预先定义了我们的期望。通常,期望以断言函数调用的形式出现在最后。有了mock,我们直接在mocked函数上定义,最后只调用verify。
在这个测试中,我们使用 once 和 withArgs 来定义一个 mock,它会检查调用的数量和给定的参数。如果我们使用存根,检查多个条件需要多个断言,这可能是代码异味。
由于为mock声明多个条件很方便,所以很容易过火。我们可以很容易地使模拟的条件比需要的更具体,这会使测试更难理解和容易破解。这也是避免多重断言的原因之一,所以在使用模拟时要记住这一点。
最佳实践和技巧
遵循这些最佳实践以避免spies、stubs和mocks的常见问题。
尽可能使用 sinon.test
当您使用spies、stubs或mocks时,请将您的测试函数包装在 sinon.test 中。这允许您使用 Sinon 的自动清理功能。没有它,如果你的测试在你的测试替身被清理之前失败,它可能会导致级联失败——更多的测试失败是由最初的失败引起的。级联故障很容易掩盖问题的真正根源,因此我们希望尽可能避免它们。
使用 sinon.test 消除了这种级联故障的情况。这是我们之前编写的测试之一:
it('should call save once', function() {
var save = sinon.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
save.restore();
sinon.assert.calledOnce(save);
});
如果 setupNewUser 在此测试中抛出异常,则意味着spies永远不会被清除,这将对任何后续测试造成严重破坏。
我们可以通过使用 sinon.test 来避免这种情况,如下所示:
it('should call save once', sinon.test(function() {
var save = this.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
sinon.assert.calledOnce(save);
}));
注意三个不同之处:在第一行中,我们用 sinon.test 包装了测试函数。在第二行中,我们使用 this.spy 而不是 sinon.spy。最后,我们删除了 save.restore 调用,因为它现在正在自动清理。
您可以将此机制用于所有三个测试替身:
- sinon.spy 变成 this.spy
- sinon.stub 变成 this.stub
- sinon.mock 变成 this.mock
使用 sinon.test 进行异步测试
使用 sinon.test 时,您可能需要禁用异步测试的虚假计时器。当将 Mocha 的异步测试与 sinon.test 一起使用时,这是一个潜在的混淆来源。
要使测试与 Mocha 异步,您可以在测试函数中添加一个额外的参数:
it('should do something async', function(done) {
当与 sinon.test 结合使用时,这可能会中断:
it('should do something async', sinon.test(function(done) {
结合这些可能会导致测试无缘无故地失败,并显示有关测试超时的消息。这是由 Sinon 的假计时器引起的,默认情况下,使用 sinon.test 包装的测试会启用这些计时器,因此您需要禁用它们。
这可以通过更改测试代码中的某处或随测试加载的配置文件中的 sinon.config 来解决:
sinon.config = {
useFakeTimers: false
};
sinon.config 控制一些函数的默认行为,如 sinon.test。它还有一些其他可用的选项。
在 beforeEach 中创建共享Stubs
如果您需要在所有测试中用stubs替换某个函数,请考虑在 beforeEach 挂钩中将其stubs。例如,我们所有的测试都使用了 Database.save 的测试替身,因此我们可以执行以下操作:
describe('Something', function() {
var save;
beforeEach(function() {
save = sinon.stub(Database, 'save');
});
afterEach(function() {
save.restore();
});
it('should do something', function() {
//you can use the stub in tests by accessing the variable
save.yields('something');
});
});
确保还添加一个 afterEach 并清理stubs。没有它,stubs可能会留在原处,并且可能会导致其他测试出现问题。
检查正在设置的函数调用或值的顺序
如果您需要检查某些函数是否按顺序调用,您可以将间谍或存根与 sinon.assert.callOrder 一起使用:
var a = sinon.spy();
var b = sinon.spy();
a();
b();
sinon.assert.callOrder(a, b);
如果需要在调用函数之前检查是否设置了某个值,可以使用 stub 的第三个参数将断言插入到 stub 中:
var object = { };
var expectedValue = 'something';
var func = sinon.stub(example, 'func', function() {
assert.equal(object.value, expectedValue);
});
doSomethingWithObject(object);
sinon.assert.calledOnce(func);
stubs中的断言确保在调用存根函数之前正确设置值。请记住还包括一个 sinon.assert.calledOnce
检查以确保调用stubs。没有它,当不调用stubs时,您的测试将不会失败。
总结
Sinon 是一个功能强大的工具,通过遵循本文中列出的做法,您可以避免开发人员在使用它时遇到的最常见问题。要记住的最重要的事情是使用 sinon.test - 否则,级联故障可能是挫败感的一大来源。