几年前,作为一名初级工程师,我在软件开发的两个方面苦苦挣扎:构建大型代码库和编写可测试的代码。测试驱动开发是一种常见的技术,通常被认为是理所当然的,但并不总是很清楚如何使代码完全可测试。
我记得读过一些例子,作者会干净地对函数进行单元测试,原则上,这是有道理的。但真正的代码看起来不像那些例子。不管写得多么周到,真正的代码都有一定程度的复杂性。
最终,很多复杂性归结为管理依赖关系。这可以说是软件工程的主要挑战之一。引用那首著名的诗,“没有人是一座自成一体的岛屿。”
本文分享了一些强大的工具来帮助您编写可测试的代码,这些代码可以发展成整洁、可管理的代码库。
但首先,我们需要问:什么是依赖关系?
什么是依赖?
依赖项是程序需要工作的任何外部资源。这些可以是代码真正依赖的外部库,也可以是程序功能所需的服务,例如互联网 API 和数据库。
我们用来管理这些依赖关系的工具是不同的,但问题最终是相同的。一个代码单元依赖于其他代码单元,这些代码单元本身通常具有依赖关系。为了使程序正常工作,必须递归地解决所有依赖关系。
如果您不熟悉包管理器的工作方式,您可能会对这个问题的复杂性感到惊讶。但是,如果您编写并尝试测试依赖于数据库的网络服务器,您可能熟悉相同问题的另一个版本。幸运的是,这是一个经过充分研究的问题。
让我们快速了解一下如何使用 SOLID 原则来提高代码的可维护性和稳定性。
坚实的原则
Robert Martin 的 SOLID 原则是编写面向对象代码的优秀指南。我认为其中两个原则——单一职责原则和依赖倒置原则——在 OO 设计之外也可能非常重要。
单一职责原则
单一职责原则指出,一个类或函数应该有一个——而且只有一个——目的,因此只有一个改变的理由。
例如,Express 处理程序函数可能会清理和验证请求,执行一些业务逻辑,并将结果存储在数据库中。此功能执行许多工作。假设我们重新设计它以遵循单一职责原则。在这种情况下,我们将输入验证、业务逻辑和数据库交互转移到三个单独的函数中,这些函数可以组合起来处理请求。处理程序本身只做它的名字所暗示的:处理一个 HTTP 请求。
UNIX 哲学——本质上,做一件事,然后把它做好。保持您的单元简单可靠,并通过组合简单的部分来实现复杂的解决方案。
依赖倒置原则
依赖倒置原则鼓励我们依赖抽象而不是具体。这也与关注点分离有关。
回到我们的 Express 处理程序示例,如果处理程序函数直接依赖于数据库连接,则会引入许多潜在问题。假设我们注意到我们的网站表现不佳并决定添加缓存;现在我们需要在我们的处理函数中管理两个不同的数据库连接,可能会在整个代码库中一遍又一遍地重复缓存检查逻辑,并增加错误的可能性。
更重要的是,处理程序中的业务逻辑通常不会关心缓存解决方案的细节;它所需要的只是数据。如果我们改为依赖于数据库的抽象,我们可以将持久性逻辑中的更改包含在内,并降低一个小的更改将迫使我们重写大量代码的风险。
我发现这些原则的问题通常在于它们的介绍。如果没有相当多的挥手,就很难在一般层面上展示它们。
我想具体解释一下。让我们看看如何使用这两个原则将一个大的、难以测试的处理程序函数分解成小的、可测试的单元。
示例: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 实用程序类来派生该类型。
我们已经开始在这里看到关注点分离的好处。我们的业务逻辑现在依赖于持久层的抽象,而不是具体的数据库连接。我们可以假设持久层将在 post 服务内部按预期工作。业务层的唯一工作是执行业务逻辑,然后将持久性职责传递给存储类。
在测试新代码之前,我们可以使用一个非常简单的函数工厂模式,使用注入的依赖项重写我们的处理函数。现在,该函数的唯一工作是验证传入请求并将其传递给应用程序的业务逻辑层。我会让您免去输入验证的无聊,因为无论如何我们都应该为此使用经过良好测试的第三方库。
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);
});
});
只需五行设置代码,我们现在就可以针对单独的隔离测试数据库测试我们的持久性代码。