组件测试示例

以下是基于 Node.js 的微服务组件测试(Component Testing)详细示例,以用户服务(User Service)为例,演示如何测试一个微服务及其直接依赖(如数据库),同时模拟外部服务(如邮件服务)。


1. 组件测试的定义

组件测试(Component Testing)关注一个微服务内部的完整功能,包括其直接依赖(如数据库、消息队列),但外部服务会被模拟或替换为测试替身。目标是验证服务在隔离环境中的行为是否符合预期。


2. 场景描述

假设用户服务(User Service)提供以下功能:

  • 注册用户:通过 HTTP POST /users 接口写入数据库。
  • 发送欢迎邮件:调用外部邮件服务(Email Service)的 API。
  • 依赖项
    • MongoDB:存储用户数据。
    • Email Service:外部 HTTP 服务(需模拟)。

3. 工具选择

工具 作用
TestContainers 启动真实的 MongoDB 容器,隔离测试环境。
Supertest 发起 HTTP 请求并断言响应结果。
Nock 模拟外部 HTTP 服务(Email Service)的响应。
Mocha 测试框架,管理测试用例和生命周期钩子。

4. 项目结构

1
2
3
4
5
6
7
8
9
user-service/
├── src/
│ ├── app.js # Express 应用
│ ├── db.js # MongoDB 连接
│ └── emailClient.js # 调用外部邮件服务的客户端
├── tests/
│ └── component/
│ └── user.spec.js # 组件测试文件
├── package.json

5. 实现代码

(1) Express 应用(src/app.js

1
2
3
4
5
6
7
const express = require("express");
const { createUser } = require("./userController");
const app = express();
app.use(express.json());

app.post("/users", createUser);
module.exports = app;

(2) 用户注册逻辑(src/userController.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { getDb } = require("./db");
const { sendWelcomeEmail } = require("./emailClient");

async function createUser(req, res) {
const { name, email } = req.body;
const db = getDb();

try {
// 写入数据库
const result = await db.collection("users").insertOne({ name, email });
const user = { id: result.insertedId, name, email };

// 调用外部邮件服务
await sendWelcomeEmail(email);

res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: "Failed to create user" });
}
}

module.exports = { createUser };

(3) 邮件服务客户端(src/emailClient.js

1
2
3
4
5
6
7
8
9
10
11
const axios = require("axios");

async function sendWelcomeEmail(email) {
await axios.post("https://api.email-service.com/send", {
to: email,
subject: "Welcome!",
body: "Welcome to our platform!",
});
}

module.exports = { sendWelcomeEmail };

6. 组件测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// tests/component/user.spec.js
const { GenericContainer } = require("testcontainers");
const { expect } = require("chai");
const supertest = require("supertest");
const nock = require("nock");
const app = require("../../src/app");
const { connect, disconnect, getDb } = require("../../src/db");

describe("User Service Component Tests", function () {
this.timeout(30000); // 设置全局超时

let container;
let db;

// 启动 MongoDB 容器并连接数据库
before(async () => {
container = await new GenericContainer("mongo:6")
.withExposedPorts(27017)
.start();

const host = container.getHost();
const port = container.getMappedPort(27017);
process.env.MONGODB_URI = `mongodb://${host}:${port}/test_db`;

await connect();
db = getDb();
});

// 清理数据库并停止容器
after(async () => {
await db.collection("users").deleteMany({});
await disconnect();
await container.stop();
nock.cleanAll(); // 清理所有 HTTP 模拟
});

// 每个测试前重置 Nock 的模拟
beforeEach(() => {
nock.cleanAll();
});

it("应成功注册用户并发送欢迎邮件", async () => {
// 模拟外部邮件服务的成功响应
nock("https://api.email-service.com")
.post("/send")
.reply(200, { success: true });

// 发送注册请求
const response = await supertest(app)
.post("/users")
.send({ name: "Alice", email: "alice@example.com" })
.expect(201);

// 验证数据库记录
const user = await db.collection("users").findOne({ _id: response.body.id });
expect(user).to.deep.include({
name: "Alice",
email: "alice@example.com",
});

// 验证外部邮件服务是否被调用
expect(nock.isDone()).to.be.true;
});

it("当邮件服务失败时应返回错误", async () => {
// 模拟外部邮件服务的失败响应
nock("https://api.email-service.com")
.post("/send")
.reply(500, { error: "Email service unavailable" });

// 发送注册请求
const response = await supertest(app)
.post("/users")
.send({ name: "Bob", email: "bob@example.com" })
.expect(500);

// 验证错误响应
expect(response.body).to.deep.equal({
error: "Failed to create user",
});

// 验证数据库无记录
const user = await db.collection("users").findOne({ email: "bob@example.com" });
expect(user).to.be.null;
});
});

7. 关键点解析

(1) 测试策略

  • 真实数据库:通过 TestContainers 启动 MongoDB,测试真实的数据库交互。
  • 模拟外部服务:使用 nock 拦截邮件服务的 HTTP 请求,避免依赖真实服务。
  • 隔离性:每个测试用例前清理数据库和 Nock 模拟,确保测试独立。

(2) 验证逻辑

  • 数据库状态:插入用户后,直接从数据库查询验证数据一致性。
  • 外部调用:通过 nock.isDone() 确认邮件服务是否被正确调用。
  • 错误处理:模拟邮件服务失败,验证服务是否能正确处理异常。

(3) 生命周期管理

  • 全局初始化:在 before 钩子中启动容器并连接数据库。
  • 资源释放:在 after 钩子中清理数据、关闭连接、停止容器。

8. 运行测试

1
npx mocha tests/component/user.spec.js

预期输出

1
2
3
4
5
User Service Component Tests
✔ 应成功注册用户并发送欢迎邮件 (256ms)
✔ 当邮件服务失败时应返回错误 (189ms)

2 passing (5s)

9. 常见问题处理

(1) 测试挂起不退出

  • 原因:未关闭 Express 服务器或存在未释放的资源。
  • 修复:显式关闭服务器(如果独立启动):
    1
    2
    const server = app.listen(0); // 在 app.js 中导出 server
    after(async () => server.close());

(2) Nock 模拟未生效

  • 原因:HTTP 请求未匹配 Nock 的拦截规则。
  • 修复:检查请求 URL 和 Body 是否完全匹配,或使用 nock.disableNetConnect() 禁用非模拟请求。

(3) 数据库连接超时

  • 原因:容器启动或连接时间过长。
  • 修复:增加 TestContainers 的超时时间:
    1
    new GenericContainer("mongo:6").withStartupTimeout(120_000);

10. 总结

通过组件测试,可以验证微服务在以下方面的行为:

  • 业务逻辑正确性:如用户注册流程。
  • 数据持久化:确保数据库操作符合预期。
  • 外部依赖交互:通过模拟验证服务间通信。

工具链扩展

  • 消息队列测试:使用 testcontainers 启动 RabbitMQ/Kafka,结合 amqplibkafkajs 测试消息生产/消费。
  • GraphQL 服务:使用 supertest 发送 GraphQL 请求并断言响应。