测试异步 JavaScript
JavaScript 社区似乎存在一个普遍的误解,即测试异步代码需要与测试“常规”同步代码不同的方法。在这篇文章中,我将解释为什么通常情况并非如此。我将重点介绍测试支持异步行为的代码单元与本质异步代码之间的区别。我还将展示基于 Promise 的异步代码如何能够实现简洁明了的单元测试,这些测试可以以清晰易读的方式进行测试,同时仍然验证异步行为。
2013 年 9 月 18 日
异步兼容代码与本质异步代码
我们作为 JavaScript 开发人员编写的多数“异步”代码并非本质异步。例如,让我们看一下使用 JQuery 实现的简单 ajax 操作
var callback = function(){ alert('I was called back asynchronously'); }; $.ajax({ type: 'GET', url: 'http://example.com', done: callback });
你可能会看到它并说“这是异步代码”。它有回调,对吧?实际上它并非本质异步,它只是支持在异步上下文中使用的可能性。因为$.ajax
通过回调而不是使用返回值(或异常)向调用者提供 AJAX 调用的结果,所以它能够异步实现其操作,但实际上它可以选择不这样做。想象一下这个$.ajax
的假版本
function ajax( opts ){ opts.done( "fake result" ); }
这显然不是完整的 XHR 实现 ;),但更重要的是它不是异步实现。通过在方法实现本身中直接调用done
回调,我们将潜在的异步操作扁平化为完全同步的操作。这对ajax
的客户端有什么影响?如果我们像这样调用我们的假 ajax 方法
console.log( 'calling ajax...' ); ajax({ done: function(){ console.log( 'callback called' ); } }); console.log( '...called ajax' );
我们会看到类似于以下内容的输出
calling ajax... callback called ...called ajax
如你所见,我们的日志条目按定义顺序写入,因为我们使用的假ajax
函数是潜在异步 API 的同步实现。所有这些代码都将由运行时同步执行,在事件循环的单个循环中[1]。
另一种思考方式是,同步操作的方法调用是更通用的异步情况的特例。同步代码只是异步代码,其中结果在原始调用的上下文中返回。
测试支持异步的代码
希望我已经成功地证明了我们编写的多数 JavaScript 代码并非本质异步,它只是支持异步行为,因为它调用了支持异步的 API——那些使用回调(或 Promise)的 API。但我们为什么要关心这一点?我们编写的代码始终会在异步上下文中使用,对吧?嗯,不,当我们想要为支持异步的代码编写单元测试时,情况并非如此。
让我们继续使用$.ajax
的示例。想象一下,我们正在编写一个函数,该函数从 URL 获取当前用户的 JSON 描述,并根据该 JSON 创建本地 User 对象。我们的实现可能看起来像这样
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
在fetchCurrentUser
中,我们向 url 发起 GET 请求,提供一个ajaxDone
回调,该回调将在请求返回响应后异步执行。在该回调中,我们获取从响应中返回的 JSON,使用parseUserJson
函数解析它以创建一个(相当贫血的)User 域对象。最后,我们调用最初传递给fetchCurrentUser
的回调,并将用户对象作为参数传递。
让我们看看如何为这段代码编写单元测试(在本帖末尾列出了测试期间使用的工具和库)。例如,我们如何测试fetchCurrentUser
是否将 JSON 解析为适当的 User 对象?在意识到本质异步代码和支持异步代码之间的区别之前,我们可能认为我们需要某种异步单元测试来测试这段异步代码。但现在我们了解到我们正在测试的代码并非本质异步。我们可以将其执行扁平化为同步流程以进行测试。让我们看看它可能是什么样子
describe('fetchCurrentUser', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); }; fetchCurrentUser(fetchCallback); }); });
我们首先要做的是用一个存根函数替换$.ajax
,该函数允许我们模拟 ajax 响应返回(直接修改$.ajax
非常糟糕,但我不想在这篇文章中讨论依赖项管理,所以我们忍一忍)。然后,我们调用fetchCurrentUser
函数,它是我们测试的主题。因为fetchCurrentUser
需要支持异步获取,所以它接受一个回调。我们在这个测试中的目标是检查调用fetchCurrentUser
的最终结果,这意味着我们需要为它提供一个回调,该回调将接收最终创建的用户对象。该回调使用Chai 的 expect 风格断言来验证用户的fullName
属性是否已正确初始化。
重要的是要注意,此测试将以完全同步的方式执行。就像我们之前的示例一样,它将在事件循环的单个循环中完成。
陷阱
这种测试方法有一个陷阱。如果我们不小心注释掉生产代码中的重要行,会发生什么情况?
function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); //callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
此函数按原样无法正常工作。它永远不会调用传递给fetchCurrentUser
的回调,这意味着永远不会返回用户对象。你可能会认为我们的测试会验证这一点,因为它明确地检查了用户fullName
属性的值。
但是,情况并非如此。测试将继续通过。为什么?好吧,我们将断言放在回调内部,而回调从未执行!该错误意味着我们的测试的一部分从未被调用,因此从未被执行。
解决此问题的一种天真的方法可能看起来像这样
describe('fetchCurrentUser', function() { it('creates a parsed user', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); callbackCalled = true; }; var callbackCalled = false; fetchCurrentUser(fetchCallback); expect(callbackCalled).to.be.true; }); });
这与之前的测试相同,只是现在我们还跟踪回调是否被调用。此测试现在将正确地检测到我们的回调没有被执行,并将失败,但这有点笨拙。如果我们使用Mocha作为我们的测试运行器(而不是Jasmine,例如),那么我们有一个稍微更好的选择
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(ajaxOpts) { var doneCallback = ajaxOpts.done; doneCallback(simulatedAjaxResponse); }; function fetchCallback(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); done(); }; fetchCurrentUser(fetchCallback); }); });
请注意,我们的it
块现在接受一个done
参数。如果 Mocha 看到你的it
块期望一个参数,那么它将把测试视为异步测试。它将向你的测试提供一个done
函数,作为回报,你的测试必须调用done()
来告诉 Mocha 测试已完成。调用done()
是我们的测试标记其控制流结束。这意味着 Mocha 能够检测到测试是否到达其控制流的末尾,如果未到达,则使测试失败。请注意,这也意味着你的测试可以根据需要真正异步执行,并通过运行循环的多个循环执行。
这种明确指示以回调为中心的测试代码何时结束的简单方法是有些人更喜欢 Mocha 而不是 Jasmine 的原因之一。Jasmine 也支持异步测试,但机制比done()
函数复杂得多。
不错,但还不够好
好的,所以我们已经看到,为以回调为中心的支持异步的代码编写单元测试非常有可能。但是,我第一个承认这些测试并不容易阅读。感觉有很多管道代码,并且代码以回调为中心的性质泄漏到我们的测试中。我认为这是一个经典的“代码异味”测试帮助我们了解的案例。如果测试看起来很糟糕或难以编写,那么测试的代码设计可能存在问题。
接下来,我将论证,从回调切换到 Promise 将帮助我们减少这种代码异味,从而产生更简洁的代码,并具有相应的令人愉快的测试。
Promise 的快速入门
在本文的其余部分,我将从以回调为中心的异步代码切换到以 Promise 为中心的代码。我发现 Promise 能够以声明式的方式对支持异步代码的控制流进行建模。同样,我认为 Promise 使对支持异步代码的单元测试进行推理变得更容易。我将在这里简要概述 Promise。如果你以前从未使用过它们,那么我强烈建议你学习更多关于它们的信息,作为使用它们来改进你自己的异步代码的第一步。
我喜欢将 Promise 视为回调的面向对象的版本,并附带了一些额外的功能。使用传统的以回调为中心的代码,当你调用异步函数时,你传递一个回调,该回调将在异步操作完成后被调用。在一个函数调用中,你既请求执行一些异步工作,又指定工作完成后要执行的下一步操作。使用 Promise,对异步操作的请求与之后要执行的操作分离。你像以前一样调用异步操作,但调用者不会将回调作为参数传递给异步函数。相反,异步函数返回一个 Promise 对象给调用者。然后,调用者在该 Promise 上注册一个回调。你调用该函数以调用异步操作,然后你通过与该函数返回的 Promise 交互来单独说明你希望在操作完成后执行的操作。
所以,代替这种以回调为中心的代码
var callback = function(){ alert('I was called back asynchronously'); }; someAsyncFunction( "some", "args", callback );
你将使用以 Promise 为中心的代码执行以下操作
var callback = function(){ alert('I was called back asynchronously'); }; var promise = someAsyncFunction( "some", "args" ); promise.done( callback );
在大多数情况下,你将使用 jQuery 风格的方法链和匿名函数,从而得到类似于以下内容的结果
someAsyncFunction( "some", "args" ).done( function(){ alert('I was called back asynchronously'); });
这只是 Promise 库提供的最基本的功能——还有很多其他功能可以利用。我在之前的博客文章中更详细地介绍了 Promise。我鼓励你阅读它以获取更多信息。该文章还包含一个更复杂的示例,展示了 Promise 库的一些更高级功能如何帮助消除代码中一些繁琐的异步管道,从而帮助将重点放在实际解决的问题上。
Dominic Denicola 在这篇文章中也很好地解释了为什么 Promise 如此有用。强烈推荐阅读。
将异步代码移植到 Promise
在我们之前的示例中,我们有以下以回调为中心的实现
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser(callback) { function ajaxDone(userJson) { var user = parseUserJson(userJson); callback(user); }; return $.ajax({ type: 'GET', url: "http://example.com/currentUser", done: ajaxDone }); };
以下是以 Promise 为中心的实现
function parseUserJson(userJson) { return { loggedIn: true, fullName: userJson.firstName + " " + userJson.lastName }; }; function fetchCurrentUser() { return Q.when($.ajax({ type: 'GET', url: "http://example.com/currentUser" })).then(parseUserJson); };
与之前一样,fetchCurrentUser
向 url 发起 GET 请求,但我们不是将回调直接传递给$.ajax
函数,而是获取该函数返回的 Promise,并将parseUserJson
函数链接到它。通过这种方式,我们安排从$.ajax
调用返回的 JSON 响应流到我们的解析器函数——在那里它被转换为解析后的 User 对象——然后继续流到调用者对fetchCurrentUser
设置的任何进一步的 Promise 管道。
请注意,我正在使用出色的Q 库来增强 JQuery 从$.ajax(...)
调用返回的不太完美的$.Deferred
Promise 实现。我之前提到的Dominic 的文章也更详细地讨论了$.Deferred
中缺少的内容。
我发现这种基于 Promise 的实现比基于回调的代码更容易阅读,并且通过用 Promise 对象替换原始回调,扩展其工作方式的选择要多得多。我们可以从类似于 $.ajax
的东西返回的 Promise 开始,然后在其基础上构建一个操作管道,这些操作在一个值上进行操作,并在其通过管道时进行转换。
基于 Promise 的实现的测试也应该说明它会导致更易读的代码。
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var userPromise = fetchCurrentUser(); userPromise.then(function(user) { expect(user.fullName).to.equal("Tomas Jakobsen"); done(); }); }); });
此测试在概念上与我们之前使用的基于回调的测试相同。我们将 $.ajax
替换为一个假的实现,它只返回一个硬编码的模拟响应,包装在一个预先解析的 Q Promise 中。然后我们调用 fetchCurrentUser
函数。最后,我们验证从 Promise 管道另一端出来的内容是否具有适当的 .fullName
属性。
我认为这种基于 Promise 的形式更易读,也更易于重构。还有更多!因为 Promise 充当异步操作的封装,我们还可以使用像 chai-as-promised 这样的优秀库来增强我们的测试运行器,这将使我们能够将测试代码重构为
describe('fetchCurrentUser', function() { it('creates a parsed user', function(done) { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var fetchResult = fetchCurrentUser(); return expect( fetchResult ).to.eventually .have.property('fullName','Tomas Jakobsen') .notify(done); }); });
我们可以更进一步。通过添加 mocha-as-promised,我们不再需要明确地告诉 Mocha 我们的测试控制流何时结束。
describe('fetchCurrentUser', function() { it('creates a parsed user', function() { var simulatedAjaxResponse = { firstName: "Tomas", lastName: "Jakobsen" }; $.ajax = function(){ return Q(simulatedAjaxResponse); } var fetchResult = fetchCurrentUser(); return expect( fetchResult ).to.eventually .have.property('fullName','Tomas Jakobse'); }); });
在这里,我们去掉了 done
函数技巧。相反,我们将代表测试异步控制流的 Promise 链 传递回 Mocha 测试运行器,在那里 mocha-as-promised 扩展能够进行后台管道操作,以确保 Promise 链在继续进行下一个测试之前结束。这是一个非常智能的功能,它内置于其他测试运行器中,例如 Buster。
这是一个很好的例子,说明了为什么 Promise 如此强大。通过将异步控制流的概念具体化,我们实际上可以将控制流传递回测试运行器,它可以直接对该控制流进行操作。至关重要的是,我们的测试实际上不需要知道框架在做什么。它们只是将控制流传递回调用方,并让它接管。
本质上,Promise 允许我们将调用操作的关注点与处理该操作结果的关注点分离。这意味着我们可以让我们的测试代码模拟一半,并测试我们的生产代码如何处理另一半。
测试本质异步代码
我已经说明了我们可以以本质上同步的方式测试支持异步的代码,但是如果你想测试真正本质上是异步的代码怎么办?
Mocha 对异步测试的支持意味着使用与我一直在展示的用于以同步方式测试支持异步的代码相同的技术,完全有可能测试真正异步的代码。但是,对于“正常”JavaScript 代码来说,本质上是异步的实际上很少见。本质上异步意味着代码明确放弃了它在事件循环中的轮次——例如通过调用 setTimeout
——或者调用本机非阻塞 API 的代码,例如 XMLHttpRequest
。我们多久会直接编写执行此操作的代码?我敢说并不经常[2]。我们集成了执行此操作的代码,以 JQuery 等库的形式,但正如我在本文中所演示的那样,我们仍然可以以同步方式测试这种集成,因为该集成代码本质上不是异步的。
积极限制您编写和维护的真正异步测试的数量。
我认为,除非您正在编写底层库,否则您几乎不需要对本质上是异步的代码进行单元测试。您可能需要测试与本质上是异步的库发生摩擦的代码,但您不会经常自己创建这种代码,因此也不会经常发现需要为这种代码编写测试。事实上,我的建议是积极限制您编写和维护的真正异步测试的数量。Martin Fowler 的 关于非确定性测试的优秀文章 详细解释了为什么这些类型的测试往往会损害测试套件的整体健康状况。
如果您确实发现自己编写了大量本质上是异步的代码,那么您可能需要退一步,确定代码中一个小的、可包含的区域,该区域处理所有这些令人讨厌的异步内容。它是难以编写和难以测试的代码。将其隔离,并使用不同类型的测试(即集成测试)对其进行彻底测试。Gerald Meszaros 对 Humble Object 模式 的文档提供了一些关于如何以干净的方式隔离真正异步代码的方法的良好解释。关于测试和包含这种棘手代码的另一个极好的建议来源是 GOOS 书籍,它详细讨论了在不同级别上对异步代码进行测试。
归根结底,我猜想大多数需要异步功能的 JavaScript“单元测试”实际上是更高层次的集成测试,它们调用数据库、DOM、Web API 等。这些类型的测试很好且有价值,但重要的是要理解它们是不同类型的测试,并且您几乎肯定可以从更大规模的更孤立的单元测试套件中获得更好的价值。但这将是另一篇文章的内容。
Colophon:使用的工具和库
在我的代码示例中,我使用了 Q Promise 实现。对于测试,我使用了 Mocha 测试运行器 与 Chai 测试断言库 配合使用。我使用 mocha-as-promised 和 chai-as-promised 库增强了此测试设置。我在 node.js 上运行测试,使用 npm 声明和安装上面提到的工具和库。
所有测试代码都与任何需要存在的 DOM 隔离,也不需要 JQuery(因为我们总是用测试替身替换 $.ajax
)。
脚注
1: 始终保持异步
有一个强有力的论据,即使您可以内联解决异步操作,您也应该通过将响应推迟到事件循环的后续轮次来保持一致的调用顺序(有关这方面的更多信息,请参见下一个脚注!)。推迟回调的执行通常使用 setImmediate
或类似方法实现。
2: Promises/A+ 规范合规性
我偷偷地忽略了一个事实,即符合 Promises/A+ 的 Promise 实现(例如 Q)需要不在 事件循环的一次轮次内 解决。
如果您不知道这意味着什么,请不要太担心。如果您知道,请原谅我没有深入探讨这个兔子洞。我认为,从概念上讲,面向 Promise 的代码仍然被扁平化为同步顺序,即使它在两轮而不是一轮内完成。
重大修订
2013 年 9 月 18 日:第二版,添加了对 Promise 的覆盖范围
2013 年 9 月 3 日:发布第一版