16. 测试 Sequelize 代码:以真实 MySQL 为准
数据库代码不能只靠 mock。Mock 可以验证 Service 是否调用某个方法,却无法证明 MySQL 的唯一约束、事务、行锁、排序规则和 JOIN 真按预期工作。本章建立适合当前项目的分层测试思路,不强制立即引入测试框架。
1. 三类测试分别证明什么
单元测试
不连接数据库,测试纯函数和分支:
assertAdminCanLogin()对状态和时间的判断;- DTO mapper 是否排除
password_hash; - 唯一约束错误映射函数;
- 分页参数、游标编码等纯逻辑。
优点是快、定位准;缺点是不能证明 SQL 正确。
数据库集成测试
连接独立 MySQL 测试库,使用真实 Sequelize Model:
- DDL 与 Model 是否一致;
- UNIQUE、FK、NULL、默认值是否生效;
- 事务是否回滚;
FOR UPDATE是否处理并发;- include 的 INNER/LEFT JOIN 与分页 count 是否正确。
这是本教程的重点。
HTTP 集成测试
从 Express 路由发送请求,覆盖 validator、Controller、Service、数据库和错误中间件。它证明整体行为,但失败时定位范围更大,不能代替较小的测试。
2. 为什么不要用 SQLite 代替全部 MySQL 测试
SQLite 适合某些快速测试,但本项目依赖 MySQL 语义:
BIGINT UNSIGNED与 mysql2 的字符串返回;utf8mb4_0900_ai_ci排序与比较;- InnoDB 行锁和
SELECT ... FOR UPDATE; - 隔离级别、死锁行为;
- MySQL 的 DDL、错误码、索引与执行计划。
SQLite 测试通过不能证明这些行为正确。单元测试可不连接数据库;需要验证数据库行为时,直接使用真实 MySQL 8 测试库更诚实。
3. 测试数据库必须独立
建议配置:
NODE_ENV=test
DB_NAME=nloop_test
DB_HOST=127.0.0.1
DB_USER=nloop_test
DB_PASSWORD=...
安全规则:
- 测试脚本启动时验证
NODE_ENV === "test"; - 数据库名必须满足明确后缀,例如
_test; - 测试账号只拥有测试库权限;
- 禁止复用开发/生产库;
- 清理数据前打印并检查目标库名;
- CI 为每次运行创建隔离数据库或容器。
不要仅依赖“我记得 .env 是测试配置”。破坏性清理必须有代码级护栏。
4. Migration 本身也要测试
最重要的数据库测试之一,是从空库按顺序执行全部 Migration:
创建空测试库
→ 执行 001、002、003……
→ 连接 Sequelize
→ authenticate
→ 最小 CRUD/约束验证
它能发现:
- Migration 顺序依赖错误;
- 只在本机手工执行、却没进入版本控制的 DDL;
- Model 使用了数据库不存在的列;
- 外键两端类型不一致;
- 字符集或索引名冲突。
只测试已有本地数据库不够,因为本地库可能残留手工修改。
5. Model 与真实表结构一致性
测试可以先做行为验证,而不是机械比较所有元数据。例如:
import { AdminLoginResult } from "../types/admin-login-audit";
const audit = await AdminLoginAudit.create({
admin_user_id: null,
attempted_username: "not_exists",
login_result: AdminLoginResult.InvalidCredentials,
ip_address: "127.0.0.1",
});
// created_at 由数据库默认值生成;MySQL create 后显式 reload 再断言。
await audit.reload();
expect(typeof audit.id).toBe("string");
expect(audit.created_at).toBeInstanceOf(Date);
然后补关键约束测试:
attempted_username省略时失败;admin_user_id = null成功;- 不存在的非空管理员 ID 失败;
- login_result 超出 CHECK 范围失败;
- 默认
created_at自动生成。
行为测试比“Model 定义中有 allowNull”更接近真正目标。不过,通过 Model 写入仍不能单独证明数据库约束:Sequelize 的 validate.isIn 可能在 SQL 发出前就拒绝非法状态。
要验证数据库 CHECK 和默认值,应补一组绕过 Model 校验的 Raw SQL 集成测试:
import { QueryTypes } from "sequelize";
await expect(
db.query(`
INSERT INTO admin_login_audit (
admin_user_id, attempted_username, login_result, ip_address
) VALUES (NULL, 'invalid_status', 9, NULL)
`),
).rejects.toThrow();
await db.query(`
INSERT INTO admin_login_audit (
admin_user_id, attempted_username, login_result, ip_address
) VALUES (NULL, 'database_default', 2, NULL)
`);
const [row] = await db.query<{ created_at: Date }>(
`
SELECT created_at
FROM admin_login_audit
WHERE attempted_username = 'database_default'
ORDER BY id DESC
LIMIT 1
`,
{ type: QueryTypes.SELECT },
);
expect(row?.created_at).toBeInstanceOf(Date);
这些测试必须运行在可清理的独立测试库。第一条证明 MySQL CHECK 生效,第二条省略 created_at,证明值确实来自数据库默认表达式,而不是 DataTypes.NOW。
6. 示例测试结构
当前项目没有固定测试框架,下面使用常见的 Vitest 风格表达结构,重点是测试边界而不是要求立即安装依赖:
describe("AdminLoginAudit model", () => {
beforeAll(async () => {
assertSafeTestDatabase();
await db.authenticate();
});
afterAll(async () => {
await db.close();
});
beforeEach(async () => {
await resetTestFixtures();
});
it("保留不存在用户名的失败审计", async () => {
const audit = await AdminLoginAudit.create({
admin_user_id: null,
attempted_username: "ghost",
login_result: AdminLoginResult.InvalidCredentials,
ip_address: "127.0.0.1",
});
expect(audit.admin_user_id).toBeNull();
expect(audit.attempted_username).toBe("ghost");
});
});
如果以后选择 Node.js 内置 node:test,断言和生命周期 API 写法会不同,但数据库隔离原则相同。引入测试依赖时应单独说明其学习作用。
7. 测试数据准备策略
每个测试显式创建最小数据
const admin = await AdminUser.create({
username: "admin_a",
password_hash: known_hash,
display_name: "Admin A",
email: null,
locked_until: null,
last_login_at: null,
});
优势是测试独立、读者能看懂前置条件。Fixture 工厂可以减少重复,但不要隐藏关键默认值。
清表
按外键依赖顺序删除:先审计子表,再管理员主表。不要在未验证库名时执行 truncate({ cascade: true }) 或全库删除。
每测试事务回滚
很多项目让每个测试运行在事务中,结束时回滚,速度快。但有局限:
- 被测代码必须使用同一个 transaction;
- 并发锁测试需要多个真实连接,单事务包装会干扰;
- 提交后的行为无法验证;
- DDL 在事务中的语义与普通 DML 不同。
所以事务回滚适合普通 CRUD 测试,不是万能清理方案。
8. 测试唯一约束
it("拒绝重复 username", async () => {
await createAdmin({ username: "same_name", email: "a@example.com" });
await expect(
createAdmin({ username: "same_name", email: "b@example.com" }),
).rejects.toBeInstanceOf(UniqueConstraintError);
});
还要单独测试重复 email,不能仅断言“两者之一冲突”。如果 Service 映射错误,则验证安全 code/message;底层错误细节只用于测试和内部日志。
MySQL 唯一索引通常允许多个 NULL,所以应明确测试多个 email = NULL 的管理员是否符合业务预期。
9. 测试事务完整回滚
it("第二步失败时第一步也回滚", async () => {
await expect(
db.transaction(async (transaction) => {
await AdminUser.update(
{ last_login_at: fixed_time },
{ where: { id: admin.id }, transaction },
);
await AdminLoginAudit.create(invalid_audit, { transaction });
}),
).rejects.toThrow();
const reloaded = await AdminUser.findByPk(admin.id);
expect(reloaded?.last_login_at).toBeNull();
});
断言最终数据库状态,而不是只断言“抛了错误”。只有状态未变化,才能证明回滚有效。
10. 测试登录失败的并发锁
目标:两个请求同时读取计数 3,最终必须得到 5,而不是丢失更新变成 4。
测试思路:
- 将
failed_login_count设置为 3; - 几乎同时调用两次
recordFailedLogin(); await Promise.all(...);- reload 管理员;
- 断言计数为 5,且
locked_until已设置。
await Promise.all([
recordFailedLogin(admin, fixed_time),
recordFailedLogin(admin, fixed_time),
]);
await admin.reload();
expect(admin.failed_login_count).toBe(5);
expect(admin.locked_until).not.toBeNull();
注意:并发测试可能存在调度偶然性。要重复运行,并考虑在测试专用代码中用 barrier 控制两个事务到达同一阶段;不能简单加一个固定 sleep 就认为可靠。
11. 测试锁定时间边界
当前 Service 允许注入 failed_at,这是良好的可测试性设计。使用固定日期:
const failed_at = new Date("2026-01-01T00:00:00.000Z");
至少覆盖:
- 第 4 次失败不锁定;
- 第 5 次失败锁定 15 分钟;
locked_until === now时视为已过期;- 锁定过期后的第一次失败从 1 开始;
- 成功登录清零并清除临时锁。
不要在断言中依赖真实 new Date(),否则边界测试容易偶发失败。
12. 测试关联与 JOIN
准备三个管理员:无审计、1 条审计、3 条审计。分别验证:
required: false返回 3 个管理员;required: true返回 2 个管理员;- include 内失败条件只加载匹配记录;
findAndCountAll({ distinct: true })的 count 是管理员数;attributes中没有password_hash;- 删除管理员后审计保留,
admin_user_id变为NULL,用户名快照不变。
不要只做快照测试整个 JSON;精确断言关系语义更容易定位问题。
13. 测试 BIGINT 字符串语义
普通自增 ID 很难快速增长到安全整数边界。可在隔离测试表或专门 Migration 中插入超过 Number.MAX_SAFE_INTEGER 的 ID:
INSERT INTO admin_user (id, username, ...)
VALUES (9007199254740993, 'large_id_admin', ...);
查询后断言:
expect(admin.id).toBe("9007199254740993");
这同时验证 mysql2 的 supportBigNumbers / bigNumberStrings 配置,不要在测试中先转换成 number。
14. HTTP 错误处理测试
通过测试 HTTP 客户端请求登录接口:
- 参数缺失返回安全 validator 消息;
- 不存在用户与错误密码响应相同;
AppError返回预期业务 code;- 模拟未知数据库错误时,对外只返回通用内部错误;
- 日志记录内部错误,但不包含 password/JWT。
验证“没有泄露”时,可断言响应文本不包含 SQL、表名、stack 和配置值。
15. 不要过度 mock Sequelize
以下 mock 只能证明“调用了 update”,不能证明事务和行锁正确:
vi.spyOn(AdminUser, "update").mockResolvedValue([1]);
纯编排 Service 可使用 mock 做快速反馈,但关键数据库行为必须有真实 MySQL 测试兜底。尤其不要自己模拟 FOR UPDATE,那几乎是在重写一个不准确的数据库。
16. 初学者高频错误
- 测试连接开发库,清理时删除真实练习数据。
- 使用
sync({ force: true }),却没验证数据库名。 - 全用 SQLite,误以为已验证 MySQL 行锁和排序规则。
- 只断言函数抛错,不断言事务后的数据库状态。
- 所有测试共享同一管理员,造成顺序依赖。
- 用真实时钟和固定 sleep 测并发边界,产生偶发失败。
- mock 掉所有 Model 方法,最终没有测试任何 SQL。
- 测试结束忘记
db.close(),进程因连接池不退出。
17. 动手练习
按顺序完成:
- 写
assertSafeTestDatabase(),拒绝非_test数据库。 - 从空测试库执行所有 Migration。
- 测 username/email 两个唯一约束及多个 NULL email。
- 测事务第二步失败后第一步回滚。
- 测两次并发登录失败不会丢失计数。
- 测
required、distinct和删除管理员后的审计保留。 - 最后增加一个 HTTP 测试,证明未知错误不会泄露 SQL。
18. 复盘问题
- 单元、数据库集成、HTTP 集成测试各证明什么?
- 为什么真实 MySQL 行为不能完全用 SQLite 替代?
- 使用事务回滚隔离测试有哪些限制?
- 为什么回滚测试必须检查最终数据库状态?
- 怎样减少并发测试的偶然性?
- 测试结束为什么需要关闭本测试进程的连接池?