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();
复制代码
现在,我们已经为这些用例保留了预期输出的参考,可以安全地重构我们的代码并确保输出保持一致再次运行这些测试了。
现在,我们有了认可测试来警示我们“热点曲目”特性的行为是否发生了变化,我们可以安全地重构该特性的实现了。
为了减少我们即将开始的重构过程中的认知负荷,让我们从以下步骤开始:
根据我们的直觉开始重命名和移动代码块是很具风险的。这样做的风险在于,最终生成的代码很难测试……
让我们换成另一种方式:编写一个测试,清楚明确地检查特性的行为,然后重构代码,以便测试能够通过。测试驱动开发过程(TDD)将帮助我们想出一个新的设计,使该功能易于测试。
我们将要编写的测试是单元测试。因此,它们运行起来非常快,不需要启动数据库,也不需要 Openwhyd 的 API 服务器。为了实现这一点,我们将提取业务逻辑,这样就可以脱离底层基础设施独立进行测试。
另外,我们这次不打算使用快照。相反,让我们确切表达人类可读的特性应该如何运行的预期,类似于早期的 BDD 应用程序。
让我们从一个非常简单的问题开始:如果 Openwhyd 上只有一首曲目,它应该被列在热门曲目的首位。
describe('hot tracks feature', () => { it('should list one track in first position, if just that track was posted', () => { const postedTrack = { name: 'a good track' }; expect(getHotTracks()).toMatchObject([ postedTrack ]); });});
复制代码
到目前为止,这个测试是有效的,但它无法通过,因为没有定义getHotTracks()。让我们提供最简单的实现,仅仅是为了让测试通过。
function getHotTracks() { return [{ name: 'a good track' }];}
复制代码
现在测试通过了,TDD 方法的第三步表明我们应该清理和/或重构我们的代码,但到目前为止还没有太多要做的!因此,让我们通过编写第二个测试来开始第二个 TDD 迭代。
这个测试没有通过,因为getHotTracks()返回的是一个为了让第一个通过测试的硬编码的值。为了让这个函数在两个测试用例中都能工作,让我们提供输入数据作为参数。
function getHotTracks(postedTracks) { // 按分数降序排序 return postedTracks.sort((a, b) => b.score - a.score);}describe('hot tracks feature', () => { it('should list one track in first position, if just that track was posted', () => { const postedTrack = { name: 'a good track' }; const postedTracks = [ postedTrack ]; expect(getHotTracks(postedTracks)).toMatchObject([ postedTrack ]); }); it('should list the track with higher score in first position, given two posted tracks with different scores', () => { const regularTrack = { name: 'a good track', score: 1 }; const popularTrack = { name: 'a very good track', score: 2 }; const postedTracks = [ regularTrack, popularTrack ]; expect(getHotTracks(postedTracks)).toMatchObject([ popularTrack, regularTrack ]); });});
复制代码
现在,我们的两个单元测试通过了一个非常基本的实现,让我们尝试让getHotTracks()更接近它的实际实现(称为getHotTracksFromDb()),即当前在生产中使用的实现。
为了保持这些测试的纯粹性(即不产生任何副作用,因此不运行任何 I/O 操作的测试),它们调用的getHotTracks()函数必须不依赖于数据库客户端。
为了实现这一点,让我们应用依赖关系注入:用
getTracksByDescendingScore()函数替换getHotTracks()的poststedtracks参数(类型:曲目的数组),该函数将提供对这些曲目的访问。这将允许getHotTracks()在需要数据时调用该函数。因此,我们将更多的控制权交给getHotTracks(),同时将如何实际获取数据的责任转交给调用者。
function getHotTracks(getTracksByDescendingScore) { return getTracksByDescendingScore();}describe('hot tracks feature', () => { it('should list one track in first position, if just that track was posted', () => { const postedTrack = { name: 'a good track' }; const getTracksByDescendingScore = () => [ postedTrack ]; expect(getHotTracks(getTracksByDescendingScore)).toMatchObject([ postedTrack ]); }); it('should list the track with higher score in first position, given two posted tracks with different scores', () => { const regularTrack = { name: 'a good track', score: 1 }; const popularTrack = { name: 'a very good track', score: 2 }; const getTracksByDescendingScore = () => [ regularTrack, popularTrack ]; expect(getHotTracks(getTracksByDescendingScore)).toMatchObject([ popularTrack, regularTrack ]); });});
复制代码
现在,我们已经让getHotTracks()的纯粹的实现更接近真实的实现,让我们从真实的实现调用它!
/* fetch top hot tracks, and include complete post data (from the "post" collection), score, and rank increment */exports.getHotTracksFromDb = async function (params = {}, handler) { const getTracksByDescendingScore = () => exports.fetchRankedTracks(params); const tracks = await getHotTracks(getTracksByDescendingScore); const pidList = snip.objArrayToValueArray(tracks, 'pId'); const posts = await fetchPostsByPid(pidList); // complete track items with additional metadata (from posts) return tracks.map((track) => { const post = posts.find(({ eId }) => eId === track.eId); return computeTrend(post ? mergePostData(track, post) : track); });}
复制代码
我们的单元测试和认可测试仍然可以工作,证明我们没有破坏任何东西!
现在,“热门曲目”模型将我们的纯粹的“热门曲目”特性逻辑称为“热门曲目”,我们可以在编写单元测试时,逐步将逻辑从第一个转移到第二个。
我们的下一步将是把来自于posts中的带有附加元数据的完整的tracks数据,从getHotTracksFromDb()移动到getHotTracks()。
我们从生产逻辑中观察到:
在转移该逻辑之前,基于这些观察,让我们将getHotTracks()的预期行为定义为一个新的单元测试。
it('should return tracks with post metadata', async () => { const posts = [ { _id: '61e19a3f078b4c9934e72ce4', eId: '1', pl: { name: 'soundtrack of my life', id: 0 }, // metadata from the post that will be included in the list of hot tracks }, { _id: '61e19a3f078b4c9934e72ce5', eId: '2', text: 'my favorite track ever!', // metadata from the post that will be included in the list of hot tracks }, ]; const getTracksByDescendingScore = () => [ { eId: posts[0].eId, pId: posts[0]._id }, { eId: posts[1].eId, pId: posts[1]._id }, ]; const fetchPostsByPid = (pidList) => posts.filter(({ _id }) => pidList.includes(_id)); const hotTracks = await getHotTracks(getTracksByDescendingScore, fetchPostsByPid); expect(hotTracks[0].pl).toMatchObject(posts[0].pl); expect(hotTracks[1].text).toMatchObject(posts[1].text);});
复制代码
为了让测试通过,我们将调用移动到fetchPostsByPid()以及它的后续逻辑,从getHotTracksFromDb()到getHotTracks()。
// 文件: app/features/hot-tracks.jsexports.getHotTracks = async function (getTracksByDescendingScore, fetchPostsByPid) { const tracks = await getTracksByDescendingScore(); const pidList = snip.objArrayToValueArray(tracks, 'pId'); const posts = await fetchPostsByPid(pidList); // complete track items with additional metadata (from posts) return tracks.map((track) => { const post = posts.find(({ eId }) => eId === track.eId); return computeTrend(post ? mergePostData(track, post) : track); });};// 文件: app/models/track.jsexports.getHotTracksFromDb = async function (params = {}, handler) { const getTracksByDescendingScore = () => new Promise((resolve) => { exports.fetch(params, resolve); }); return feature.getHotTracks(getTracksByDescendingScore, fetchPostsByPid);};
复制代码
此时,我们将所有的数据操作逻辑转移到getHotTracks(),而getHotTracksFromDb()只包含必要的管道,以向它提供来自数据库的实际数据。
要让测试通过,我们只需要做最后一件事:将fetchPostsByPid()函数作为参数传递给getHotTracks()。对于我们的两个初始测试,fetchPostsByPid()可以返回一个空数组。
it('should list one track in first position, if just that track was posted', async () => { const postedTrack = { name: 'a good track' }; const getTracksByDescendingScore = () => [ postedTrack ]; const fetchPostsByPid = () => []; expect(await getHotTracks(getTracksByDescendingScore, fetchPostsByPid)) .toMatchObject([postedTrack]);});it('should list the track with higher score in first position, given two posted tracks with different scores', async () => { const regularTrack = { name: 'a good track', score: 1 }; const popularTrack = { name: 'a very good track', score: 2 }; const getTracksByDescendingScore = () => [ popularTrack, regularTrack ]; const fetchPostsByPid = () => []; expect(await getHotTracks(getTracksByDescendingScore, fetchPostsByPid)) .toMatchObject([ popularTrack, regularTrack ]);});it('should return tracks with post metadata', async () => { const posts = [ { _id: '61e19a3f078b4c9934e72ce4', eId: '1', pl: { name: 'soundtrack of my life', id: 0 }, // metadata from the post that will be included in the list of hot tracks }, { _id: '61e19a3f078b4c9934e72ce5', eId: '2', text: 'my favorite track ever!', // metadata from the post that will be included in the list of hot tracks }, ]; const getTracksByDescendingScore = () => [ { eId: posts[0].eId, pId: posts[0]._id }, { eId: posts[1].eId, pId: posts[1]._id }, ]; const fetchPostsByPid = (pidList) => posts.filter(({ _id }) => pidList.includes(_id)); const hotTracks = await getHotTracks(getTracksByDescendingScore, fetchPostsByPid); expect(hotTracks[0].pl).toMatchObject(posts[0].pl); expect(hotTracks[1].text).toMatchObject(posts[1].text);});
复制代码
现在,我们已经成功地将业务逻辑从getHotTracksFromDb()提取到getHotTracks(),并使用单元测试覆盖了该纯粹的逻辑,我们可以安全地删除之前编写的防止该函数回归的认可测试:它会呈现排名的曲目。
我们可以遵循完全相同的过程完成剩下的两个用例:
我们改进了代码库的可测试性和测试方法:
采用这些在面向对象编程语言(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/,获取更多精彩内容!