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()函数是做什么的:
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 的热门曲目逻辑的唯一方法是:
这两个解决方案都需要启动并在 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 }, ]; } }, }), },});
复制代码
更重要的是,这样做意味着自动化测试依赖于独立于业务逻辑的实现细节。
两个原因:
这意味着即使业务逻辑没有改变,有时我们也必须更新我们的自动化测试!
在我们的例子中,如果我们决定在测试中模拟 mongodb 依赖,编写和更新测试将需要更多的工作。为了避免这种情况,开发人员可能会被劝着去升级依赖关系、更改数据模型,或者更甚:一开始就编写测试!
当然,我们宁愿节省一些时间去做更重要的事情,比如实现新功能!
提示:当依赖模拟来测试紧密耦合的代码时,即使业务逻辑没有改变,自动化测试也可能会失败。从长远来看,模拟数据库查询会使测试更不稳定,可读性更差。
依赖注入
根据前面的示例,模拟数据库查询不太可能是测试业务逻辑的可行的、长期的方法。
我们是否可以抽象业务逻辑和数据源 mongodb 之间的依赖关系,作为一种替代方法?
是的。我们可以通过让特性的调用者注入一种让业务逻辑获取所需数据的方法,来解耦特性及其底层的数据获取逻辑。
在实践中,我们不是从我们的模型中导入 mongodb,而是将该模型作为一个参数传递,以便调用者可以在运行时指定该数据源的任何实现。
下面是如何将 getHotTracks()函数转换为 TypeScript 中表达的类型:
exports.getHotTracks = async function ( fetchRankedTracks: () => Track[], fetchCorrespondingPosts: (tracks: Track[]) => Post[]) { const tracks = await fetchRankedTracks(); const posts = await fetchCorrespondingPosts(tracks); // [...]
复制代码
这种方式:
结论:依赖注入有助于业务逻辑和数据持久层之间的解耦。我们可以重构紧耦合的代码,以使其更容易理解和维护,并为其编写健壮和快速的单元测试。
小心驶得万年船
在前一节中,我们了解了依赖注入如何帮助业务逻辑和数据持久层之间的解耦。
为了防止在重构当前实现时出现 bug,我们应该确保重构不会对特性的行为产生任何影响。
为了检测紧密耦合的代码中没有被自动化测试充分覆盖的行为变化,我们可以编写认可测试。认可测试预先收集曲目,在实现变更后再次执行检查这些曲目是否保持不变。它们是临时的,直到有可能为我们的业务逻辑编写更好的测试(例如单元测试)为止。
在我们的例子中:
因此,我们应该能够通过发出 API 请求并观察结果响应中的变化和/或 tracks 数据集合的状态来检测功能回归。
// 注意:在运行这些测试之前,确保MongoDB和Openwhyd服务器正在运行。describe("Hot Tracks (approval tests - to be replaced later by unit tests)", () => { beforeEach(async () => { await mongodb.clearDatabase(); }); it("renders ranked tracks", async () => { await mongodb.tracks.insertMany([ { name: "a regular track", score: 1 }, { name: "a popular track", score: 2 }, ]); const serverURL = await startOpenwhydServer(); const html = await httpClient.get(`${serverURL}/hot`); expect(html).toMatchSnapshot(); //注意:上面的请求在"tracks"集合=>中做了改变,不需要快照该集合的状态。 }); it("updates the score of a track when it's reposted", async () => { const users = [ { id: 0, name: "user 0", pwd: "123" }, { id: 1, name: "user 1", pwd: "456" }, ]; await mongodb.users.insertMany(users); const serverURL = await startOpenwhydServer(); const userSession = [ await httpClient.post(`${serverURL}/api/login`, users[0]), await httpClient.post(`${serverURL}/api/login`, users[1]), ]; const posts = [ // user 0 posts track A await httpClient.post( `${serverURL}/api/post`, { action: "insert", eId: "track_A" }, { cookies: userSession[0].cookies } ), // user 0 posts track B await httpClient.post( `${serverURL}/api/post`, { action: "insert", eId: "track_B" }, { cookies: userSession[0].cookies } ), ]; // user 1 reposts track A await httpClient.post( `${serverURL}/api/post`, { action: "insert", pId: posts[0].pId }, { cookies: userSession[1].cookies } ); const ranking = await httpClient.get(`${serverURL}/hot?format=json`); expect(ranking).toMatchSnapshot(); //注意:上面的请求更新"tracks"集合=>,我们也快照该集合的状态。 const tracksCollection = await mongodb.tracks.find({}).toArray(); expect(tracksCollection).toMatchSnapshot(); });});
复制代码
请注意,这些测试可以按原样针对 Openwhyd 的 API 运行,因为它们只操作外部接口。因此,这些认可测试也可以作为灰盒测试或端到端 API 测试。
我们第一次运行这些测试时,这些测试运行程序将为每个测试断言生成包含传递给toMatchSnapshot()的数据的快照文件。在将这些文件提交到我们的版本控制系统(例如 git)之前,我们必须检查数据是否正确,是否足以作为参考。因此有了这个名字:”认可测试”。
注意:重要的是要阅读测试函数的实现,以发现这些测试必须覆盖的参数和特征。例如,getHotTracks()函数接受一个用于分页的 limit 和 skip 参数,并且它合并从 post 集合获取的额外的数据。确保相应地增加认可测试的覆盖范围,以检测该逻辑所有关键部分的回归。
问题:相同的逻辑,不同的曲目
提交快照并重新运行认可测试后,您可能会发现它们失败了!
Jest 告诉我们,每次运行时对象标识符和日期都不一样……
为了解决这个问题,我们在将结果传递给 Jest 的toMatchSnapshot()函数之前,用占位符替换动态值:
const { _id } = await httpClient.post( `${serverURL}/api/post`, { action: "insert", pId: posts[0].pId }, { cookies: userSession[1].cookies } ); const cleanJSON = (body) => body.replaceAll(_id, '__posted_track_id__'); const ranking = await httpClient.get(`${serverURL}/hot?format=json`); expect(cleanJSON(ranking)).toMatchSnapshot();
复制代码
现在,我们已经为这些用例保留了预期输出的参考,可以安全地重构我们的代码并确保输出保持一致再次运行这些测试了。
为单元测试重构
现在,我们有了认可测试来警示我们“热点曲目”特性的行为是否发生了变化,我们可以安全地重构该特性的实现了。
为了减少我们即将开始的重构过程中的认知负荷,让我们从以下步骤开始:
- 删除所有死代码和/或注释掉的代码;
- 在异步函数调用上使用 await,而不是在 promise 上传递回调或调用.then();(这将大大简化编写测试和移动代码块的过程)
- 在依赖于数据库的遗留函数的名称后面添加上FromDb后缀,以便与我们即将引入的新函数有明显的区分。(例如,将getHotTracks()函数重命名为getHotTracksFromDb(),并将fetchRankedTracks()函数重命名为fetchRankedTracksFromDb())
根据我们的直觉开始重命名和移动代码块是很具风险的。这样做的风险在于,最终生成的代码很难测试……
让我们换成另一种方式:编写一个测试,清楚明确地检查特性的行为,然后重构代码,以便测试能够通过。测试驱动开发过程(TDD)将帮助我们想出一个新的设计,使该功能易于测试。
我们将要编写的测试是单元测试。因此,它们运行起来非常快,不需要启动数据库,也不需要 Openwhyd 的 API 服务器。为了实现这一点,我们将提取业务逻辑,这样就可以脱离底层基础设施独立进行测试。
另外,我们这次不打算使用快照。相反,让我们确切表达人类可读的特性应该如何运行的预期,类似于早期的 BDD 应用程序。
让我们从一个非常简单的问题开始:如果 Openwhyd 上只有一首曲目,它应该被列在热门曲目的首位。
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!