16. 测试 Sequelize 代码:以真实 MySQL 为准

数据库代码不能只靠 mock。Mock 可以验证 Service 是否调用某个方法,却无法证明 MySQL 的唯一约束、事务、行锁、排序规则和 JOIN 真按预期工作。本章建立适合当前项目的分层测试思路,不强制立即引入测试框架。

1. 三类测试分别证明什么

单元测试

不连接数据库,测试纯函数和分支:

优点是快、定位准;缺点是不能证明 SQL 正确。

数据库集成测试

连接独立 MySQL 测试库,使用真实 Sequelize Model:

这是本教程的重点。

HTTP 集成测试

从 Express 路由发送请求,覆盖 validator、Controller、Service、数据库和错误中间件。它证明整体行为,但失败时定位范围更大,不能代替较小的测试。

2. 为什么不要用 SQLite 代替全部 MySQL 测试

SQLite 适合某些快速测试,但本项目依赖 MySQL 语义:

SQLite 测试通过不能证明这些行为正确。单元测试可不连接数据库;需要验证数据库行为时,直接使用真实 MySQL 8 测试库更诚实。

3. 测试数据库必须独立

建议配置:

NODE_ENV=test
DB_NAME=nloop_test
DB_HOST=127.0.0.1
DB_USER=nloop_test
DB_PASSWORD=...

安全规则:

不要仅依赖“我记得 .env 是测试配置”。破坏性清理必须有代码级护栏。

4. Migration 本身也要测试

最重要的数据库测试之一,是从空库按顺序执行全部 Migration:

创建空测试库
  → 执行 001、002、003……
  → 连接 Sequelize
  → authenticate
  → 最小 CRUD/约束验证

它能发现:

只测试已有本地数据库不够,因为本地库可能残留手工修改。

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);

然后补关键约束测试:

行为测试比“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 }) 或全库删除。

每测试事务回滚

很多项目让每个测试运行在事务中,结束时回滚,速度快。但有局限:

所以事务回滚适合普通 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。

测试思路:

  1. 将 failed_login_count 设置为 3;
  2. 几乎同时调用两次 recordFailedLogin();
  3. await Promise.all(...);
  4. reload 管理员;
  5. 断言计数为 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");

至少覆盖:

不要在断言中依赖真实 new Date(),否则边界测试容易偶发失败。

12. 测试关联与 JOIN

准备三个管理员:无审计、1 条审计、3 条审计。分别验证:

不要只做快照测试整个 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 客户端请求登录接口:

验证“没有泄露”时,可断言响应文本不包含 SQL、表名、stack 和配置值。

15. 不要过度 mock Sequelize

以下 mock 只能证明“调用了 update”,不能证明事务和行锁正确:

vi.spyOn(AdminUser, "update").mockResolvedValue([1]);

纯编排 Service 可使用 mock 做快速反馈,但关键数据库行为必须有真实 MySQL 测试兜底。尤其不要自己模拟 FOR UPDATE,那几乎是在重写一个不准确的数据库。

16. 初学者高频错误

  1. 测试连接开发库,清理时删除真实练习数据。
  2. 使用 sync({ force: true }),却没验证数据库名。
  3. 全用 SQLite,误以为已验证 MySQL 行锁和排序规则。
  4. 只断言函数抛错,不断言事务后的数据库状态。
  5. 所有测试共享同一管理员,造成顺序依赖。
  6. 用真实时钟和固定 sleep 测并发边界,产生偶发失败。
  7. mock 掉所有 Model 方法,最终没有测试任何 SQL。
  8. 测试结束忘记 db.close(),进程因连接池不退出。

17. 动手练习

按顺序完成:

  1. 写 assertSafeTestDatabase(),拒绝非 _test 数据库。
  2. 从空测试库执行所有 Migration。
  3. 测 username/email 两个唯一约束及多个 NULL email。
  4. 测事务第二步失败后第一步回滚。
  5. 测两次并发登录失败不会丢失计数。
  6. 测 required、distinct 和删除管理员后的审计保留。
  7. 最后增加一个 HTTP 测试,证明未知错误不会泄露 SQL。

18. 复盘问题