Node.js作为后端框架,自 2009 年首次发布以来,已被越来越多的公司广泛采用。它的成功有以下几个原因:JavaScript 语言(又称 Web 语言)的应用,一个丰富的开源模块和工具的生态系统,以及它简单高效的原型 API。
不幸的是,简单是一把双刃剑。一个简单的 Node.js API,随着增长会变得越来越复杂,缺乏软件设计和最佳实践经验的开发人员可能很快就会被软件熵、偶然的复杂性或技术债务所淹没。
此外,JavaScript 语言的灵活性很容易被滥用,正常可用的原型在生产环境中跑着跑着就会很快变成不可维护的怪物。在使用 Node.js 启动一个项目时,很容易会忽视传统上与 Java 和 C#等 OOP 语言一起使用的最佳实践(例如SOLID原则),当然,这说不好会更好,还是会更坏。
当我帮助我的客户(大多数是刚起步的公司)改进他们的 Node.js 代码库时,以及在我编写的开源项目中,我感受到了软件熵的痛苦。例如,在维护 10 年前开始编写的 Node.js 应用程序 openwhyd.org 时,我面临着越来越多的挑战。我经常在客户的 Node.js 代码库中发现类似的挑战:正在增加的功能会破坏看似不相关的功能,bug 变得难以检测和修复,自动化测试编写起来很有挑战性,运行速度慢,而且会因为奇怪的原因失败……
让我们来探究一下为什么有些 Node.js 代码库比其他的更难测试。并探讨编写简单、健壮和快速检查业务逻辑的测试的几种技术。包括依赖注入(即 SOLID 的“D”),认可测试以及(剧透警告)没有模拟(mock)!
测试业务逻辑
举一个实际的例子,我们介绍一下 Openwhyd 中一个还没有被自动化测试覆盖到的特性:“热门曲目(Hot Tracks)”。
这个功能是一个在过去 7 天内 Openwhyd 用户最常发布、喜欢和播放的音乐排行榜。
它由三个用例组成:
显示曲目排行列表;
当一首歌曲被发布、转发、点赞和/或播放时,更新排名;
通过曲目排名的变化,显示每首歌曲的流行趋势(即上升、下降还是稳定)。
为了防止在这三个用例的愉快路径上出现回归,让我们将下列测试用例描述为行为驱动开发(BDD)场景:
让我们想象一下如何将第一个场景变成一个理想的自动化测试:
在这个阶段,这个测试不会通过,因为getHotTracks()需要一个数据库连接,我们的测试没有提供,并且storeTracks() 还没有实现。
从现在开始,通过测试将是我们的目标。为了更好地理解为什么“热门曲目”难以以这种方式进行测试,让我们来研究一下当前的实现。
为什么这个测试不能通过(当前)
目前,Openwhyd 的热门曲目特性由几个从models/tracks.js文件导出的函数组成:
- getHotTracks()被 HotTracks API 控制器调用,在渲染之前获取排序的曲目列表;
- updateByEid()在曲目被用户更新或删除时被调用,以更新其流行度得分;
- snapshotTrackScores() 在每个星期天都调用,以便计算在下一周中显示的每个曲目的趋势。
让我们看看getHotTracks()函数是做什么的:
为这个函数编写单元测试很复杂,因为它的业务逻辑(例如,计算每个曲目的趋势)与一个数据查询交织在一起,该数据查询发送到一个全局的 MongoDB 连接(mongodb.js)。
这意味着,在当前的实现中,测试 Openwhyd 的热门曲目逻辑的唯一方法是:
通过发送 API 请求到一个连接到 MongoDB 服务器的正在运行的 Openwhyd 服务器,从而把这个系统作为一个黑盒来进行测试;
在初始化依赖的 MongoDB 数据库后,直接调用这些函数。
这两个解决方案都需要启动并在 MongoDB 数据库服务器上造数。这将使我们的测试实现起来很复杂,运行起来也很慢。
结论:业务逻辑与 I/O(例如数据库查询)耦合使编写测试变得困难,降低了它们的执行速度,并使这些测试变得脆弱。
模拟的问题
避免依赖 MongoDB 数据库运行测试的一种方法是使用Jest所谓的“mock”来模拟该数据库。(或称之为“桩”,正如 Martin Fowler 在《模拟不是桩》中给出的定义)
注入模拟要求测试运行程序将待测系统使用的依赖项(例如,我们服务器使用的数据库客户端)与一个假冒的版本热交换,以便自动化测试可以覆盖该依赖项的行为。
在我们的例子中,fetchRankedTracks()函数调用mongodb.tracks.find(),从 mongodb 模块导入。因此,我们的自动化测试可以设置一个假的内存数据库,将数据查询重定向到它,而不是真的去查询一个实际的 MongoDB 数据库:
这完全可行。
但是,如果测试中的特性多次调用同一个函数进行不同的查询,该怎么办?
在这种情况下,初始化它们的模拟和测试很快就会变得更大、更复杂,因而更难维护。
更重要的是,这样做意味着自动化测试依赖于独立于业务逻辑的实现细节。
两个原因:
mock 将与我们的数据模型的实现绑定在一起,也就是说,每当我们决定重构它时,我们都必须重写它们(例如重命名属性);
mock 会被绑定到被替换的依赖的接口上,也就是说,每当我们升级 mongodb 到一个新的版本,或者我们决定将数据库查询迁移到一个不同的 ORM 时,我们都必须重写它们。
这意味着即使业务逻辑没有改变,有时我们也必须更新我们的自动化测试!
在我们的例子中,如果我们决定在测试中模拟 mongodb 依赖,编写和更新测试将需要更多的工作。为了避免这种情况,开发人员可能会被劝着去升级依赖关系、更改数据模型,或者更甚:一开始就编写测试!
当然,我们宁愿节省一些时间去做更重要的事情,比如实现新功能!
提示:当依赖模拟来测试紧密耦合的代码时,即使业务逻辑没有改变,自动化测试也可能会失败。从长远来看,模拟数据库查询会使测试更不稳定,可读性更差。
依赖注入
根据前面的示例,模拟数据库查询不太可能是测试业务逻辑的可行的、长期的方法。
我们是否可以抽象业务逻辑和数据源 mongodb 之间的依赖关系,作为一种替代方法?
是的。我们可以通过让特性的调用者注入一种让业务逻辑获取所需数据的方法,来解耦特性及其底层的数据获取逻辑。
在实践中,我们不是从我们的模型中导入 mongodb,而是将该模型作为一个参数传递,以便调用者可以在运行时指定该数据源的任何实现。
下面是如何将 getHotTracks()函数转换为 TypeScript 中表达的类型:
这种方式:
在getHotTracks()调用时可以基于我们的应用程序的执行环境,注入fetchRankedTracks()和fetchCorrespondingPosts()的不同实现:基于 mongodb 的实现将被用于生产,而自定义的内存实现将针对每个自动化测试进行实例化;
我们不需要启动数据库服务器,也不需要运行测试来注入模拟,就可以测试模型的逻辑;
当数据库客户机的 API 变更时,自动化测试不需要更新。
结论:依赖注入有助于业务逻辑和数据持久层之间的解耦。我们可以重构紧耦合的代码,以使其更容易理解和维护,并为其编写健壮和快速的单元测试。
小心驶得万年船
在前一节中,我们了解了依赖注入如何帮助业务逻辑和数据持久层之间的解耦。
为了防止在重构当前实现时出现 bug,我们应该确保重构不会对特性的行为产生任何影响。
为了检测紧密耦合的代码中没有被自动化测试充分覆盖的行为变化,我们可以编写认可测试。认可测试预先收集曲目,在实现变更后再次执行检查这些曲目是否保持不变。它们是临时的,直到有可能为我们的业务逻辑编写更好的测试(例如单元测试)为止。
在我们的例子中:
- 在输入(或触发器)方面:当 HTTP 请求被/hot和/api/post端点接收,由 Openwhyd 的 API 触发“热门曲目”特性;
- 在输出(或曲目)方面:这些 HTTP 端点提供响应,并可能在 tracks 数据集合中插入和/或更新对象。
因此,我们应该能够通过发出 API 请求并观察结果响应中的变化和/或 tracks 数据集合的状态来检测功能回归。
请注意,这些测试可以按原样针对 Openwhyd 的 API 运行,因为它们只操作外部接口。因此,这些认可测试也可以作为灰盒测试或端到端 API 测试。
我们第一次运行这些测试时,这些测试运行程序将为每个测试断言生成包含传递给toMatchSnapshot()的数据的快照文件。在将这些文件提交到我们的版本控制系统(例如 git)之前,我们必须检查数据是否正确,是否足以作为参考。因此有了这个名字:"认可测试"。
注意:重要的是要阅读测试函数的实现,以发现这些测试必须覆盖的参数和特征。例如,getHotTracks()函数接受一个用于分页的 limit 和 skip 参数,并且它合并从 post 集合获取的额外的数据。确保相应地增加认可测试的覆盖范围,以检测该逻辑所有关键部分的回归。
问题:相同的逻辑,不同的曲目
提交快照并重新运行认可测试后,您可能会发现它们失败了!
Jest 告诉我们,每次运行时对象标识符和日期都不一样……
为了解决这个问题,我们在将结果传递给 Jest 的toMatchSnapshot()函数之前,用占位符替换动态值:
现在,我们已经为这些用例保留了预期输出的参考,可以安全地重构我们的代码并确保输出保持一致再次运行这些测试了。
为单元测试重构
现在,我们有了认可测试来警示我们“热点曲目”特性的行为是否发生了变化,我们可以安全地重构该特性的实现了。
为了减少我们即将开始的重构过程中的认知负荷,让我们从以下步骤开始:
删除所有死代码和/或注释掉的代码;
在异步函数调用上使用 await,而不是在 promise 上传递回调或调用.then();(这将大大简化编写测试和移动代码块的过程)
在依赖于数据库的遗留函数的名称后面添加上FromDb后缀,以便与我们即将引入的新函数有明显的区分。(例如,将getHotTracks()函数重命名为getHotTracksFromDb(),并将fetchRankedTracks()函数重命名为fetchRankedTracksFromDb())
根据我们的直觉开始重命名和移动代码块是很具风险的。这样做的风险在于,最终生成的代码很难测试……
让我们换成另一种方式:编写一个测试,清楚明确地检查特性的行为,然后重构代码,以便测试能够通过。测试驱动开发过程(TDD)将帮助我们想出一个新的设计,使该功能易于测试。
我们将要编写的测试是单元测试。因此,它们运行起来非常快,不需要启动数据库,也不需要 Openwhyd 的 API 服务器。为了实现这一点,我们将提取业务逻辑,这样就可以脱离底层基础设施独立进行测试。
另外,我们这次不打算使用快照。相反,让我们确切表达人类可读的特性应该如何运行的预期,类似于早期的 BDD 应用程序。
让我们从一个非常简单的问题开始:如果 Openwhyd 上只有一首曲目,它应该被列在热门曲目的首位。
到目前为止,这个测试是有效的,但它无法通过,因为没有定义getHotTracks()。让我们提供最简单的实现,仅仅是为了让测试通过。
现在测试通过了,TDD 方法的第三步表明我们应该清理和/或重构我们的代码,但到目前为止还没有太多要做的!因此,让我们通过编写第二个测试来开始第二个 TDD 迭代。
这个测试没有通过,因为getHotTracks()返回的是一个为了让第一个通过测试的硬编码的值。为了让这个函数在两个测试用例中都能工作,让我们提供输入数据作为参数。
现在,我们的两个单元测试通过了一个非常基本的实现,让我们尝试让getHotTracks()更接近它的实际实现(称为getHotTracksFromDb()),即当前在生产中使用的实现。
为了保持这些测试的纯粹性(即不产生任何副作用,因此不运行任何 I/O 操作的测试),它们调用的getHotTracks()函数必须不依赖于数据库客户端。
为了实现这一点,让我们应用依赖关系注入:用getTracksByDescendingScore()函数替换getHotTracks()的poststedtracks参数(类型:曲目的数组),该函数将提供对这些曲目的访问。这将允许getHotTracks()在需要数据时调用该函数。因此,我们将更多的控制权交给getHotTracks(),同时将如何实际获取数据的责任转交给调用者。
现在,我们已经让getHotTracks()的纯粹的实现更接近真实的实现,让我们从真实的实现调用它!
我们的单元测试和认可测试仍然可以工作,证明我们没有破坏任何东西!
现在,“热门曲目”模型将我们的纯粹的“热门曲目”特性逻辑称为“热门曲目”,我们可以在编写单元测试时,逐步将逻辑从第一个转移到第二个。
我们的下一步将是把来自于posts中的带有附加元数据的完整的tracks数据,从getHotTracksFromDb()移动到getHotTracks()。
我们从生产逻辑中观察到:
与tracks类似,posts是通过调用fetchPostsByPid()函数从数据库中获取的,所以我们将不得不再次对该函数应用依赖注入;
track和post集合之间的数据由eId和pId两个字段关联。
在转移该逻辑之前,基于这些观察,让我们将getHotTracks()的预期行为定义为一个新的单元测试。
为了让测试通过,我们将调用移动到fetchPostsByPid()以及它的后续逻辑,从getHotTracksFromDb()到getHotTracks()。
此时,我们将所有的数据操作逻辑转移到getHotTracks(),而getHotTracksFromDb()只包含必要的管道,以向它提供来自数据库的实际数据。
要让测试通过,我们只需要做最后一件事:将fetchPostsByPid()函数作为参数传递给getHotTracks()。对于我们的两个初始测试,fetchPostsByPid()可以返回一个空数组。
现在,我们已经成功地将业务逻辑从getHotTracksFromDb()提取到getHotTracks(),并使用单元测试覆盖了该纯粹的逻辑,我们可以安全地删除之前编写的防止该函数回归的认可测试:它会呈现排名的曲目。
我们可以遵循完全相同的过程完成剩下的两个用例:
- 基于 BDD 场景编写单元测试,
- 重构底层函数,让测试通过,
- 删除相应的认可测试。
结论
我们改进了代码库的可测试性和测试方法:
- 研究了一个生产代码的例子,因为业务逻辑与数据库查询紧密耦合,所以测试起来很复杂;
- 讨论了针对逻辑编写自动化测试时,依赖数据库(真实的或模拟的)的缺点;
- 编写了认可测试,以检测重构逻辑时可能发生的任何功能回归;
- 按照 TDD,使用依赖注入原则(又称“SOLID”中的“D”)逐步地重构逻辑;
- 删除认可测试,支持我们在此过程中编写的纯粹的、人类可读的单元测试。
采用这些在面向对象编程语言(OOP)中被广泛接受和应用的模式和原则(例如 SOLID),可以帮助我们编写更好的测试,并使我们的代码库更易于维护,同时保持 JavaScript 和 TypeScript 环境的人类工程学。
我要感谢我的同事Julien Topçu (SHODO的技术教练),感谢他对概念的评审和改进,以及用于改进测试方法的示例。
作者简介
Adrien Joly,他自 2006 年以来一直是一名软件工程师,长驻于法国巴黎。他主要为初创公司工作。他关心的是,通过定期与技术和功能协作者进行协调和回顾,生产出既有用又易于编写的软件。在编写了他的第一个基于 node .js 的全栈 web 应用程序(openwhyd.org)十年之后,他仍然在生产环境中维护它,并使用它来实践遗留的代码重构技术。2020 年 3 月,Adrien Joly 加入咨询机构SHODO,与志趣相投的专业人士一起成长,并作为一名软件工匠实践自己的技能。你可以关注 Adrien 或者在 Twitter 上联系他:@adrienjoly。
原文链接:
Writing Automated Tests on a Legacy Node.js Back-end
译者简介
冬雨,小小技术宅一枚,现从事研发过程改进及质量改进方面的工作,关注研发、测试、软件工程、敏捷、DevOps、云计算、人工智能等领域,非常乐意将国外新鲜的 IT 资讯和深度技术文章翻译分享给大家,已翻译出版《深入敏捷测试》、《持续交付实战》。
了解更多软件开发与相关领域知识,点击访问 InfoQ 官网:https://www.infoq.cn/,获取更多精彩内容!