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 模块导入。因此,我们的自动化测试可以设置一个假的内存数据库,将数据查询重定向到它,而不是真的去查询一个实际的 MongoDB 数据库:
jest.mock(“mongodb.js”, {
tracks: {
find: (queryObj, { sort }) => ({
toArray: () => ([
{ name: ‘track1’, score: 1 },
{ name: ‘track2’, score: 2 },
]),
}),
},
});
这完全可行。
但是,如果测试中的特性多次调用同一个函数进行不同的查询,该怎么办?
async function compareGenres(genre1, genre2) {
const tracks1 = await mongodb.tracks.find({ genre: genre1 }).toArray();
const tracks1 = await mongodb.tracks.find({ genre: genre2 }).toArray();
// […]
}
在这种情况下,初始化它们的模拟和测试很快就会变得更大、更复杂,因而更难维护。
jest.mock(“mongodb”, {
tracks: {
find: (queryObj, params) => ({
toArray: () => {
if (queryObj === ‘rock’) {
return [
{ name: ‘track1’, score: 1 },
{ name: ‘track2’, score: 2 },
];
} else if (queryObj === ‘hip hop’) {
return [
{ name: ‘track3’, score: 1 },
{ name: ‘track4’, score: 2 },
];
}
},
}),
},
});
更重要的是,这样做意味着自动化测试依赖于独立于业务逻辑的实现细节。
两个原因:
mock 将与我们的数据模型的实现绑定在一起,也就是说,每当我们决定重构它时,我们都必须重写它们 (例如重命名属性);
mock 会被绑定到被替换的依赖的接口上,也就是说,每当我们升级 mongodb 到一个新的版本,或者我们决定将数据库查询迁移到一个不同的 ORM 时,我们都必须重写它们。
这意味着即使业务逻辑没有改变,有时我们也必须更新我们的自动化测试!
在我们的例子中,如果我们决定在测试中模拟 mongodb 依赖,编写和更新测试将需要更多的工作。为了避免这种情况,开发人员可能会被劝着去升级依赖关系、更改数据模型,或者更甚:一开始就编写测试!
当然,我们宁愿节省一些时间去做更重要的事情,比如实现新功能!
提示:当依赖模拟来测试紧密耦合的代码时,即使业务逻辑没有改变,自动化测试也可能会失败。从长远来看,模拟数据库查询会使测试更不稳定,可读性更差。
依赖注入
根据前面的示例,模拟数据库查询不太可能是测试业务逻辑的可行的、长期的方法。
我们是否可以抽象业务逻辑和数据源 mongodb 之间的依赖关系,作为一种替代方法?
是的。我们可以通过让特性的调用者注入一种让业务逻辑获取所需数据的方法,来解耦特性及其底层的数据获取逻辑。
在实践中,我们不是从我们的模型中导入 mongodb,而是将该模型作为一个参数传递,以便调用者可以在运行时指定该数据源的任何实现。
下面是如何将 getHotTracks() 函数转换为 TypeScript 中表达的类型:
exports.getHotTracks = async function (
fetchRankedTracks: () => Track[],
fetchCorrespondingPosts: (tracks: Track[]) => Post[]
) {
const tracks = await fetchRankedTracks();
const posts = await fetchCorrespondingPosts(tracks);
// […]
这种方式:
在 getHotTracks() 调用时可以基于我们的应用程序的执行环境,注入 fetchRankedTracks() 和 fetchCorrespondingPosts() 的不同实现:基于 mongodb 的实现将被用于生产,蓝狮注册而自定义的内存实现将针对每个自动化测试进行实例化;
我们不需要启动数据库服务器,也不需要运行测试来注入模拟,就可以测试模型的逻辑;
当数据库客户机的 API 变更时,自动化测试不需要更新。
结论:依赖注入有助于业务逻辑和数据持久层之间的解耦。我们可以重构紧耦合的代码,以使其更容易理解和维护,并为其编写健壮和快速的单元测试。
小心驶得万年船
在前一节中,我们了解了依赖注入如何帮助业务逻辑和数据持久层之间的解耦。
为了防止在重构当前实现时出现 bug,我们应该确保重构不会对特性的行为产生任何影响。
为了检测紧密耦合的代码中没有被自动化测试充分覆盖的行为变化,我们可以编写认可测试。认可测试预先收集曲目,在实现变更后再次执行检查这些曲目是否保持不变。它们是临时的,直到有可能为我们的业务逻辑编写更好的测试 (例如单元测试) 为止。
在我们的例子中:
在输入 (或触发器) 方面:当 HTTP 请求被 /hot 和 /api/post 端点接收,由 Openwhyd 的 API 触发“热门曲目”特性;
在输出 (或曲目) 方面:这些 HTTP 端点提供响应,并可能在 tracks 数据集合中插入和 / 或更新对象。
因此,我们应该能够通过发出 API 请求并观察结果响应中的变化和 / 或 tracks 数据集合的状态来检测功能回归。
0 Comments