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) 场景:
给定由不同数量的用户发布的曲目列表 当访问者访问“热门曲目”页面时 那么以受欢迎程度降序排列曲目 给定两首相同配乐的歌曲 当用户转发其中一首歌曲时 那么这首歌就会登上“热门曲目”排行榜的榜首 给定两周前发布的两首歌曲,分数略有不同 分数最低的曲目会在1周后被转发 当分数被计算出来时 那么在“热门曲目”页面,显示被转发曲目的排名“上升”
让我们想象一下如何将第一个场景变成一个理想的自动化测试:
describe("Hot Tracks", () => { it("displays the tracks in descending order of popularity", async () => { const regularTrack = { popularityScore: 1 }; const popularTrack = { popularityScore: 2 }; storeTracks([ regularTrack, popularTrack ]); expect(await getHotTracks()).toMatchObject([ popularTrack, regularTrack ]); }); });
在这个阶段,这个测试不会通过,因为 getHotTracks() 需要一个数据库连接,我们的测试没有提供,并且 storeTracks() 还没有实现。
从现在开始,通过测试将是我们的目标。为了更好地理解为什么“热门曲目”难以以这种方式进行测试,让我们来研究一下当前的实现。
为什么这个测试不能通过 (当前)
目前,Openwhyd 的热门曲目特性由几个从 models/tracks.js 文件导出的函数组成:
- getHotTracks() 被 HotTracks API 控制器调用,在渲染之前获取排序的曲目列表 ;
- updateByEid() 在曲目被用户更新或删除时被调用,以更新其流行度得分 ;
- snapshotTrackScores() 在每个星期天都调用,以便计算在下一周中显示的每个曲目的趋势。
让我们看看 getHotTracks() 函数是做什么的:
const mongodb = require('./mongodb.js'); /* fetch top hot tracks, without processing */ const fetchRankedTracks = (params) => mongodb.tracks .find({}, { sort: [['score', 'desc']], ...params }) .toArray(); exports.getHotTracks = async function (params = {}) { const tracks = await fetchRankedTracks(params); const pidList = snip.objArrayToValueArray(tracks, 'pId'); const posts = await fetchPostsByPid(pidList); return tracks.map((track, i) => { const post = posts.find(({ eId }) => eId === track.eId); return computeTrend(post ? mergePostData(track, post) : track); }); };
为这个函数编写单元测试很复杂,因为它的业务逻辑 (例如,计算每个曲目的趋势) 与一个数据查询交织在一起,该数据查询发送到一个全局的 MongoDB 连接 ( mongodb .js )。
这意味着,在当前的实现中,测试 Openwhyd 的热门曲目逻辑的唯一方法是:
- 通过发送 API 请求到一个连接到 MongoDB 服务器的正在运行的 Openwhyd 服务器,从而把这个系统作为一个黑盒来进行测试;
- 在初始化依赖的 MongoDB 数据库后,直接调用这些函数。
这两个解决方案都需要启动并在 MongoDB 数据库服务器上造数。这将使我们的测试实现起来很复杂,运行起来也很慢。
结论:业务逻辑与 I/O(例如数据库查询) 耦合使编写测试变得困难,降低了它们的执行速度,并使这些测试变得脆弱。
模拟的问题
避免依赖 MongoDB 数据库运行测试的一种方法是使用 Jest 所谓的“mock”来模拟该数据库。(或称之为“桩”,正如 Martin Fowler 在《模拟不是桩》中给出的定义)
注入模拟要求测试运行程序将待测系统使用的依赖项 (例如,我们服务器使用的数据库客户端) 与一个假冒的版本热交换,以便自动化测试可以覆盖该依赖项的行为。
在我们的例子中, fetchRankedTracks() 函数调用 mongodb .tracks.find() ,从 mongodb 模块导入。因此,我们的自动化测试可以设置一个假的内存数据库,将数据查询重定向到它,而不是真的去查询一个实际的 Mo