为遗留 Node.js 后端编写自动化测试(2)
依赖注入根据前面的示例,模拟数据库查询不太可能是测试业务逻辑的可行的、长期的方法。
我们是否可以抽象业务逻辑和数据源 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 数据集合的状态来检测功能回归。
// 注意:在运行这些测试之前,确保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),
await httpClient.post(`${serverURL}/api/login`, users),
];
const posts = [
// user 0 posts track A
await httpClient.post(
`${serverURL}/api/post`,
{ action: "insert", eId: "track_A" },
{ cookies: userSession.cookies }
),
// user 0 posts track B
await httpClient.post(
`${serverURL}/api/post`,
{ action: "insert", eId: "track_B" },
{ cookies: userSession.cookies }
),
];
// user 1 reposts track A
await httpClient.post(
`${serverURL}/api/post`,
{ action: "insert", pId: posts.pId },
{ cookies: userSession.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.pId },
{ cookies: userSession.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 上只有一首曲目,它应该被列在热门曲目的首位。
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()。
我们从生产逻辑中观察到:
·与tracks类似,posts是通过调用fetchPostsByPid()函数从数据库中获取的,所以我们将不得不再次对该函数应用依赖注入 ;
· track和post集合之间的数据由eId和pId两个字段关联。
在转移该逻辑之前,基于这些观察,让我们将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.eId, pId: posts._id },
{ eId: posts.eId, pId: posts._id },
];
const fetchPostsByPid = (pidList) => posts.filter(({ _id }) => pidList.includes(_id));
const hotTracks = await getHotTracks(getTracksByDescendingScore, fetchPostsByPid);
expect(hotTracks.pl).toMatchObject(posts.pl);
expect(hotTracks.text).toMatchObject(posts.text);
});为了让测试通过,我们将调用移动到fetchPostsByPid()以及它的后续逻辑,从getHotTracksFromDb()到getHotTracks()。
// 文件: app/features/hot-tracks.js
exports.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.js
exports.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();
});
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.eId, pId: posts._id },
{ eId: posts.eId, pId: posts._id },
];
const fetchPostsByPid = (pidList) => posts.filter(({ _id }) => pidList.includes(_id));
const hotTracks = await getHotTracks(getTracksByDescendingScore, fetchPostsByPid);
expect(hotTracks.pl).toMatchObject(posts.pl);
expect(hotTracks.text).toMatchObject(posts.text);
});现在,我们已经成功地将业务逻辑从 getHotTracksFromDb() 提取到 getHotTracks(),并使用单元测试覆盖了该纯粹的逻辑,我们可以安全地删除之前编写的防止该函数回归的认可测试:它会呈现排名的曲目。
我们可以遵循完全相同的过程完成剩下的两个用例:
·基于 BDD 场景编写单元测试,
· 重构底层函数,让测试通过,
· 删除相应的认可测试。
结论
我们改进了代码库的可测试性和测试方法:
· 研究了一个生产代码的例子,因为业务逻辑与数据库查询紧密耦合,所以测试起来很复杂 ;
· 讨论了针对逻辑编写自动化测试时,依赖数据库 (真实的或模拟的) 的缺点;
· 编写了认可测试,以检测重构逻辑时可能发生的任何功能回归 ;
· 按照 TDD,使用依赖注入原则 (又称“SOLID”中的“D”) 逐步地重构逻辑 ;
· 删除认可测试,支持我们在此过程中编写的纯粹的、人类可读的单元测试。
采用这些在面向对象编程语言 (OOP) 中被广泛接受和应用的模式和原则 (例如 SOLID),可以帮助我们编写更好的测试,并使我们的代码库更易于维护,同时保持 JavaScript 和 TypeScript 环境的人类工程学。
页:
[1]