JavaScript 中的依赖注入:轻松编写可测试的代码
作为一名初级工程师,我在软件开发中遇到了两个难题:构建大型代码库和编写可测试的代码。测试驱动开发是一种非常常见的技术,常常被人们视为理所当然,但如何才能使代码完全可测试却并不总是那么清晰。
我记得看过一些例子,作者会非常清晰地对函数进行单元测试,从理论上讲,这很合理。但实际代码并非如此。无论编写得多么精妙,实际代码都具有一定的复杂性。
归根结底,很多复杂性都源于依赖关系的管理。这可以说是软件工程面临的主要挑战之一;正如那首著名的诗所说,“没有人是一座孤岛,可以自全”。
本文分享了一些强大的工具,可以帮助您编写可测试的代码,并逐步构建出简洁、易于管理的代码库。
但首先,我们需要问:什么是依赖关系?
什么是依赖关系?
依赖项是指程序运行所需的任何外部资源。这些资源可以是代码实际依赖的外部库,也可以是程序功能所需的服务,例如互联网API和数据库。
我们用来管理这些依赖关系的工具各不相同,但问题的本质却是一样的。一段代码依赖于其他代码单元,而这些代码单元本身又常常存在依赖关系。为了使程序正常运行,所有依赖关系都必须递归地解决。
如果你不熟悉包管理器的工作原理,你可能会对这个问题的复杂性感到惊讶。但是,如果你编写并尝试测试过依赖数据库的 Web 服务器,你可能已经熟悉类似问题的另一种形式。幸运的是,这是一个已被深入研究的问题。
让我们快速了解一下如何使用 SOLID 原则来提高代码的可维护性和稳定性。
SOLID 原则
Robert Martin 的 SOLID 原则是编写面向对象代码的优秀指导原则。我认为其中两条原则——单一职责原则和依赖倒置原则——在面向对象设计之外也至关重要。
单一职责原则
单一职责原则指出,一个类或函数应该只有一个用途,因此也应该只有一个修改的理由。这与UNIX 的理念类似——本质上就是做好一件事,并且把它做好。保持你的单元简单可靠,并通过组合简单的组件来实现复杂的解决方案。
例如,一个 Express 处理函数可能负责清理和验证请求、执行一些业务逻辑,并将结果存储到数据库中。这个函数承担了许多任务。假设我们按照单一职责原则重新设计它。在这种情况下,我们将输入验证、业务逻辑和数据库交互分别移到三个独立的函数中,这三个函数可以组合起来处理请求。处理函数本身只做它名称所暗示的事情:处理 HTTP 请求。
依赖倒置原理
依赖倒置原则鼓励我们依赖抽象概念而非具体实现。这也与关注点分离有关。
回到我们之前的 Express 处理程序示例,如果处理函数直接依赖于数据库连接,就会引入一系列潜在问题。假设我们发现网站性能不佳,并决定添加缓存;现在我们需要在处理函数中管理两个不同的数据库连接,这可能会导致整个代码库中缓存检查逻辑的重复,从而增加出现 bug 的可能性。
此外,处理程序中的业务逻辑通常不需要关心缓存方案的细节;它只需要数据。如果我们依赖于数据库的抽象层,就可以将持久化逻辑的变更控制在一定范围内,从而降低因小改动而导致大量代码重写的风险。
我发现这些原则的问题往往在于它们的表述方式;很难在不进行大量含糊其辞的情况下,以概括的方式阐述它们。
我想具体解释一下。让我们来看看如何运用这两个原则,将一个庞大且难以测试的处理函数拆分成一个个易于测试的小单元。
示例:Node.js 中过载的 Express 处理程序
我们的示例是一个 Express 处理函数,它接收一个 POST 请求,并在 Node.js 开发人员的招聘网站上创建一个职位列表。它会验证输入内容并存储该列表。如果用户是已获批准的雇主,则该职位列表会立即公开;否则,该职位列表将被标记为待审核。
const app = express();
app.use(express.json());
let db: Connection;
const title = { min: 10, max: 100 };
const description = { min: 250, max: 10000 };
const salary = { min: 30000, max: 500000 };
const workTypes = ["remote", "on-site"];
app.post("/", async (req, res) => {
// validate input
const input = req.body?.input;
try {
const errors: Record<string, string> = {};
if (
input.jobTitle.length < title.min ||
input.jobTitle.length > title.max
) {
errors.jobTitle = `must be between ${title.min} and ${title.max} characters`;
}
if (
input.description.length < description.min ||
input.jobTitle.length > description.max
) {
errors.description = `must be between ${description.min} and ${description.max} characters`;
}
if (Number(input.salary) === NaN) {
errors.salary = `salary must be a number`;
} else if (input.salary < salary.min || input.salary > salary.max) {
errors.salary = `salary must be between ${salary.min} and ${salary.max}`;
}
if (!workTypes.includes(input.workType.toLowerCase())) {
errors.workType = `must be one of ${workTypes.join("|")}`;
}
if (Object.keys(errors).length > 0) {
res.status(400);
return res.json(errors);
}
} catch (error) {
res.status(400);
return res.json({ error });
}
const userId = req.get("user-id");
try {
// retrieve the posting user and check privileges
const [[user]]: any = await db.query(
"SELECT id, username, is_approved FROM user WHERE id = ?",
[userId]
);
const postApprovedAt = Boolean(user.is_approved) ? new Date() : null;
const [result]: any = await db.query(
"INSERT INTO post (job_title, description, poster_id, salary, work_type, approved_at) VALUES (?, ?, ?, ?, ?, ?)",
[
input.jobTitle,
input.description,
user.id,
input.salary,
input.workType,
postApprovedAt,
]
);
res.status(200);
res.json({
ok: true,
postId: result.insertId,
});
} catch (error) {
res.status(500);
res.json({ error });
}
});
这个函数存在很多问题:
1. 它承担的任务太多,实际上难以测试。
如果不能连接到正常运行的数据库,我们就无法测试验证是否有效;如果不能构建完整的 HTTP 请求,我们就无法测试从数据库中存储和检索帖子。
2. 它取决于一个全局变量。
或许我们不希望测试污染我们的开发数据库。当数据库连接被硬编码为全局连接时,我们如何指示函数使用不同的数据库连接(甚至是模拟连接)?
3. 它很重复。
任何其他需要根据用户 ID 获取用户的处理程序,本质上都会重复此处理程序的代码。
JavaScript 中用于分离关注点的分层架构
假设每个函数或类只执行一个操作。在这种情况下,一个函数需要处理用户交互,另一个函数需要执行所需的业务逻辑,还有一个函数需要与数据库交互。
你可能很熟悉的一种常见的视觉隐喻是分层架构。分层架构通常被描绘成四层堆叠在一起,数据库位于最底层,API接口位于最顶层。
不过,在考虑注入依赖项时,我发现把这些层想象成洋葱的层层结构会更有帮助。每一层都必须包含其所有依赖项才能正常运行,而且只有直接接触另一层的层才能与其进行直接交互:
例如,表示层不应该直接与持久层交互;业务逻辑应该在业务层,然后业务层可以调用持久层。
这样做的好处可能乍一看并不明显——听起来好像我们只是在给自己设限,让事情变得更难。的确,这样编写代码可能需要更长时间,但我们投入时间是为了让代码在未来更易读、更易维护、更易于测试。
关注点分离:一个例子
这就是我们开始分离关注点时实际发生的情况。我们将从管理存储在数据库中的数据的类开始(持久层的一部分):
// Class for managing users stored in the database
class UserStore {
private db: Connection;
constructor(db: Connection) {
this.db = db;
}
async findById(id: number): Promise<User> {
const [[user]]: any = await this.db.query(
"SELECT id, username, is_approved FROM user WHERE id = ?",
[id]
);
return user;
}
}
// Class for managing job listings stored in the database
class PostStore {
private db: Connection;
constructor(db: Connection) {
this.db = db;
}
async store(
jobTitle: string,
description: string,
salary: number,
workType: WorkType,
posterId: number,
approvedAt?: Date
): Promise<Post> {
const [result]: any = await this.db.query(
"INSERT INTO post (job_title, description, poster_id, salary, work_type, approved_at) VALUES (?, ?, ?, ?, ?, ?)",
[jobTitle, description, posterId, salary, workType, approvedAt]
);
return {
id: result.insertId,
jobTitle,
description,
salary,
workType,
posterId,
};
}
}
请注意,这些类非常简单——实际上,它们简单到根本不需要是类。您可以编写一个返回普通 JavaScript 对象的函数,甚至可以编写“函数工厂”来将依赖项注入到您的函数单元中。就我个人而言,我喜欢使用类,因为它们可以非常轻松地将一组方法与其依赖项关联起来,形成一个逻辑单元。
但 JavaScript 并非生来就是面向对象的语言,许多 JS 和 TS 开发者更喜欢函数式或过程式的编程风格。很简单!让我们使用一个返回普通对象的函数来实现同样的目标:
// Service object for managing business logic surrounding posts
export function PostService(userStore: UserStore, postStore: PostStore) {
return {
store: async (
jobTitle: string,
description: string,
salary: number,
workType: WorkType,
posterId: number
) => {
const user = await userStore.findById(posterId);
// if posting user is trusted, make the job available immediately
const approvedAt = user.approved ? new Date() : undefined;
const post = await postStore.store(
jobTitle,
description,
salary,
workType,
posterId,
approvedAt
);
return post;
},
};
}
这种方法的一个缺点是,返回的服务对象没有明确定义的类型。我们需要显式地定义一个类型,并将其标记为函数的返回类型,或者在其他地方使用 TypeScript 工具类来派生类型。
我们已经开始看到关注点分离带来的好处。现在,我们的业务逻辑依赖于持久层的抽象层,而不是具体的数据库连接。我们可以假设持久层在后置服务内部能够按预期工作。业务层的唯一职责是执行业务逻辑,然后将持久化工作交给存储类。
在测试新代码之前,我们可以使用非常简单的函数工厂模式,通过注入依赖项来重写处理函数。现在,该函数的唯一任务是验证传入的请求并将其传递给应用程序的业务逻辑层。由于我们应该使用经过充分测试的第三方库来完成输入验证,因此我就不赘述其中的繁琐部分了。
export const StorePostHandlerFactory =
(postService: ReturnType<typeof PostService>) =>
async (req: Request, res: Response) => {
const input = req.body.input;
// validate input fields ...
try {
const post = await postService.store(
input.jobTitle,
input.description,
input.salary,
input.workType,
Number(req.headers.userId)
);
res.status(200);
res.json(post);
} catch (error) {
res.status(error.httpStatus);
res.json({ error });
}
};
此函数返回一个包含所有依赖项的 Express 处理函数。我们调用该工厂函数并传入所需的依赖项,然后将其注册到 Express,就像我们之前的内联解决方案一样。
app.post("/", StorePostHandlerFactory(postService));
我觉得这段代码的结构现在更符合逻辑了。我们有了可以独立测试并在需要时重用的原子单元,无论是类还是函数。但是,我们是否显著提高了代码的可测试性呢?让我们尝试编写一些测试来验证一下。
测试我们的新设备
遵循单一职责原则意味着我们只对一段代码所实现的唯一目的进行单元测试。
理想的持久层单元测试无需检查主键是否正确递增。我们可以假定底层行为正常,甚至可以完全用硬编码实现来替代它们。理论上,如果所有单元单独运行都正确,那么它们组合在一起时也会正确运行(当然,这显然并非总是如此——这就是我们编写集成测试的原因)。
我们提到的另一个目标是单元测试不应该产生副作用。
对于持久层单元测试而言,这意味着我们运行的单元测试不会影响我们的开发数据库。我们可以通过模拟数据库来实现这一点,但我认为如今容器和虚拟化技术的成本如此低廉,我们完全可以使用一个真实的、但不同的数据库来进行测试。
在我们最初的示例中,如果不修改应用程序的全局配置或在每个测试中修改全局连接变量,这是不可能实现的。但现在我们注入了依赖项,这就变得非常简单了:
describe("PostStore", () => {
let testDb: Connection;
const testUserId: number = 1;
beforeAll(async () => {
testDb = await createConnection("mysql://test_database_url");
});
it("should store a post", async () => {
const post = await postStore.store(
"Senior Node.js Engineer",
"Lorem ipsum dolet...",
78500,
WorkType.REMOTE,
testUserId,
undefined
);
expect(post.id).toBeDefined();
expect(post.approvedAt).toBeFalsy();
expect(post.jobTitle).toEqual("Senior Node.js Engineer");
expect(post.salary).toEqual(78500);
});
});
只需五行设置代码,我们现在就可以针对一个独立的、隔离的测试数据库来测试我们的持久化代码。
即兴嘲讽
但如果我们想测试“更高”层级(例如业务层类)中的某个单元该怎么办?让我们来看以下场景:
如果职位列表数据来自未经预先批准立即发布的用户,则发布
服务应存储时间戳为空的帖子approved_at。
因为我们只测试业务逻辑,所以不需要测试存储或预先批准应用程序用户的过程。我们甚至不需要测试职位发布信息是否实际存储在磁盘数据库中。
得益于运行时反射的强大功能和 JavaScript 的底层动态特性,我们的测试框架很可能允许我们动态地用硬编码的“模拟”对象替换这些组件。流行的 JavaScript 测试库Jest就内置了这种功能,许多其他库也提供了类似的功能(例如SinonJS)。
让我们为这个场景编写一个测试,使用一些简单的模拟对象将其与任何实际的持久化或数据库逻辑隔离开来。
describe("PostService", () => {
let service: ReturnType<typeof PostService>;
let postStore: PostStore;
let userStore: UserStore;
const testUserId = 1;
beforeAll(async () => {
const db = await createConnection("mysql://test_database_url");
postStore = new PostStore(db);
userStore = new UserStore(db);
service = PostService(userStore, postStore);
});
it("should require moderation for new posts from unapproved users", async () => {
// for this test case, the user store should return an unapproved user
jest
.spyOn(userStore, "findById")
.mockImplementationOnce(async (id: number) => ({
id,
username: "test-user",
approved: false,
}));
// mocking the post store allows us to validate the data being stored, without actually storing it
jest
.spyOn(postStore, "store")
.mockImplementationOnce(
async (
jobTitle: string,
description: string,
salary: number,
workType: WorkType,
posterId: number,
approvedAt?: Date | undefined
) => {
expect(approvedAt).toBeUndefined();
return {
id: 1,
jobTitle,
description,
salary,
workType,
posterId,
approvedAt,
};
}
);
const post = await service.store(
"Junior Node.js Developer",
"Lorem ipsum dolet...",
47000,
WorkType.REMOTE,
testUserId
);
expect(post.id).toEqual(1);
expect(post.posterId).toEqual(testUserId);
});
});
嘲讽的好处
这里所说的模拟,只是暂时用可预测的替代方法(没有外部依赖项)替换函数或类方法,我们可以在其中执行以下操作:
- 测试高层传递进来的数据。
- 完全控制比我们当前测试层更低层代码的行为。
最后一部分功能非常强大。它允许我们测试特定类型的错误是否返回准确的 HTTP 状态码,而无需实际破坏系统来创建这些错误。
我们无需断开与测试数据库的连接即可测试数据库连接被拒绝时是否会导致 HTTP 响应中出现 500 内部服务器错误。我们可以简单地模拟调用数据库的持久化代码,并抛出与该场景中相同的异常。隔离测试并测试小型单元使我们能够进行更彻底的测试,从而确保高层所依赖的行为已正确定义。
在隔离良好的单元测试中,我们可以模拟任何依赖项。我们可以用模拟的 HTTP 客户端替换第三方 Web API,这些模拟客户端比真实的 API 更快、更便宜、更安全。如果您想确保应用程序在外部 API 出现故障时也能正常运行,您可以将其替换为一个始终在部分测试中返回 503 的依赖项。
我知道我在这里其实是在推销模拟(mocking),但理解模拟依赖项在小型、聚焦的单元测试中的强大作用,对我来说简直是醍醐灌顶。我听过“不要测试框架”这句话无数次,但只有在使用模拟之后,我才真正明白如何才能只测试开发者自己负责的行为。这让我的工作轻松了很多,也希望这些信息能对你有所帮助。
关于模拟依赖项时测试框架的说明
我在上面的例子中使用了 Jest。然而,在面向对象代码中模拟依赖关系更通用(在某些方面也更优越)的方法是使用多态和继承。
你可以扩展依赖类,添加模拟方法实现,或者将依赖项定义为接口,并编写完全隔离的类来满足这些接口,以用于测试目的。Jest 的优势在于,它允许你轻松地一次性模拟方法,而无需定义新的类型。
用于 TypeScript 和 JavaScript 的依赖注入库
既然我们开始把依赖关系看作是一种有向图,你可能会注意到实例化和注入依赖关系的过程很快就会变得多么繁琐。
TypeScript 和 JavaScript 提供了多种库来自动解析依赖关系图。这些库要求您手动列出类的依赖项,或者结合运行时反射和装饰器来推断依赖关系图的结构。
Nest.js是一个值得关注的框架,它使用依赖注入,结合了装饰器和显式依赖声明。
对于现有项目,或者如果您不想使用像 Nest 这样带有强烈主观色彩的框架,可以使用TypeDI和TSyringe等库来提供帮助。
总结
在这篇文章中,我们选取了一个不堪重负的函数作为具体示例,并将其替换为一系列更小、更易于测试的代码单元。即使两个版本的代码测试覆盖率完全相同,当新版本中的测试失败时,我们也能准确地知道究竟是哪里出了问题以及原因。
以前,我们只能大致知道出了问题,然后我们可能会翻阅错误消息和堆栈跟踪,以找出导致异常的输入、破坏性更改是什么等等。
我希望这个具体的例子有助于解释单一职责和依赖倒置这两个关键的 SOLID 原则。
值得注意的是,这并非万能的解决方案。我们的最终目标是代码的可维护性和可靠性,而简洁的代码更容易维护。控制反转是管理复杂性的有效工具,但这并非在简单程序中引入不必要复杂性的理由。
下次再见,祝您编程愉快!
PS:如果您喜欢这篇文章,请订阅我们的 JavaScript Sorcery 邮件列表,每月我们将深入探讨更多神奇的 JavaScript 技巧和窍门。
PPS:如果您需要适用于 Node.js 应用的 APM,请前往查看 AppSignal APM for Node.js。
文章来源:https://dev.to/appsignal/dependency-injection-in-javascript-write-testable-code-easily-2aef
