如果你在测试中 Mock 数据库,你实际上只是在验证仓库层调用了正确的方法。你并没有测试数据能否在崩溃后存活、唯一约束是否真的会阻止重复数据、或者事务失败时是否会回滚。

这个区别很重要。Mock 的 save() 返回你预设的值。真实的 save() 可能死锁、悄悄截断字符串,或者写入一个比主库滞后三秒的从库。你的测试套件一片绿,而你的用户正在丢数据。

为什么 Mock 数据库测试会给人虚假的安全感

大多数持久化测试长这样:

// A test that verifies syntax, not physics
jest.mock('./db', () => ({
  query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));

it('saves a user', async () => {
  const repo = new UserRepository();
  const user = await repo.create({ name: 'Ada' });
  expect(user.id).toBe(1);
});

这个测试证明了你的 SQL 语法正确,且映射器没有抛异常。但它对持久化本身什么也没证明。

真正的风险藏在「查询执行了」和「数据已持久化」之间的缝隙里。你的 ORM 在返回前是否刷新了写缓冲区?连接池是否复用了一个已经回滚的事务?ON CONFLICT DO NOTHING 是否吞掉了你期望成功的插入?

你不可能用一个按需返回 { id: 1 } 的 Mock 发现这些 Bug。

往返测试:最诚实、最简单的测试

最基础的持久化测试也是最诚实的。写入数据,读回来,断言一致。

import { createConnection } from './db';

it('persists and retrieves a user', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });

  const found = await repo.findById('user-1');
  expect(found).toEqual({
    id: 'user-1',
    name: 'Ada',
    email: 'ada@example.com',
  });
});

这能抓住明显的失败:漏提交、列映射错误、序列化 Bug。它也能抓住隐蔽的问题。如果你的 create 方法在写入前对邮箱做了哈希,而 findById 查询前没做哈希,这个测试就会失败。Mock 测试则不会。

在约束真正生效的地方测试约束

数据库约束就是业务逻辑。它们和代码里的其他分支一样值得测试。

it('enforces unique emails at the database level', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', email: 'ada@example.com' });

  await expect(
    repo.create({ id: 'user-2', email: 'ada@example.com' })
  ).rejects.toThrow(/unique constraint/i);
});

这个测试在唯一真正强制执行业务规则的地方记录了这条规则。如果后来有人为了「修复」一个不稳定迁移而移除了唯一索引,这个测试会尖叫。Mock 会欣然允许两个用户共享同一个邮箱,因为它根本没有索引的概念。

事务行为不是理论问题

你的应用可能会把多个写入包在一个事务里。测试这个事务应该证明部分失败不会让数据库处于不一致状态。

it('rolls back the entire transfer on failure', async () => {
  const db = await createConnection();
  const accounts = new AccountRepository(db);

  await accounts.create({ id: 'A', balance: 100 });
  await accounts.create({ id: 'B', balance: 50 });

  // Force a failure mid-transaction by violating a check constraint
  const transfer = db.transaction(async (trx) => {
    const acc = new AccountRepository(trx);
    await acc.debit('A', 30); // succeeds
    await acc.debit('B', 9999); // exceeds balance, throws
  });

  await expect(transfer).rejects.toThrow();

  const a = await accounts.findById('A');
  const b = await accounts.findById('B');
  expect(a.balance).toBe(100); // rollback preserved original state
  expect(b.balance).toBe(50);
});

这个测试能帮你避免凌晨 3 点的告警——账户里的钱不见了,因为下游 API 超时,借方提交了,贷方却没有。Mock 无法模拟事务隔离。只有真实的数据库才能做到。

隔离:最难的部分

每个人都会提出的异议是速度和稳定性。如果每个测试都访问 PostgreSQL,你的套件会慢得像爬,而且测试之间会互相干扰。

有三种实用的解决方案,而且你可能会三种都用。

模板数据库。 PostgreSQL 和大多数正经数据库都支持 CREATE DATABASE ... TEMPLATE。你先用 schema 初始化一个数据库,然后在毫秒级为每个测试文件克隆一份。

// test-setup.ts
import { execSync } from 'child_process';

let templateCreated = false;

export async function createIsolatedDatabase() {
  if (!templateCreated) {
    execSync('createdb test_template');
    execSync('psql test_template -f schema.sql');
    templateCreated = true;
  }
  const dbName = `test_${process.pid}_${Date.now()}`;
  execSync(`createdb -T test_template ${dbName}`);
  return connect(dbName);
}

每个测试文件拿到一个全新的数据库。不需要清理逻辑。运行结束后,删掉克隆即可。

事务作为隔离边界。 如果模板数据库太重,就在每个测试里跑一个事务,最后回滚。大多数数据库客户端都显式支持这个做法。

afterEach(async () => {
  await db.query('ROLLBACK');
});

it('inserts a row', async () => {
  await db.query('BEGIN');
  await repo.create({ id: 'x' });
  expect(await repo.findById('x')).toBeDefined();
  // rolled back by afterEach
});

问题在于,本身使用事务的代码可能与外层回滚事务冲突。通过 SAVEPOINT 实现的嵌套事务通常能解决这个问题,但并非每个驱动都处理得干净。

Testcontainers 保证 CI 一致性。 如果你的测试在本地跑 SQLite、生产跑 PostgreSQL,那你测试的是另一个数据库。用 Testcontainers 在 Docker 里为每次 CI 启动真实的数据库。

import { PostgreSqlContainer } from '@testcontainers/postgresql';

const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());

它会给测试运行增加 2-3 秒。但它消灭了整个类别的 Bug——那些只在 Postgres 15 且 standard_conforming_strings = on 时才能复现的 Bug。

什么该 Mock,什么不该 Mock

我并不是主张零 Mock。Mock 在你无法控制的边界上是正确的:Stripe API 调用、S3 上传、SMTP 服务器。这些操作慢、贵、而且有限流。

你的数据库不是同一种意义上的外部边界。它是你运营的基础设施。它的行为是确定性的。它的 schema 是你写的代码。Mock 它,意味着用一个你实际运行的系统的幻想版本来做测试。

取舍在于速度。真实的数据库测试耗时 20-50ms,Mock 测试只需 2-5ms。如果你有一万个持久化测试,这个差异就很重要。大多数团队并没有一万个持久化测试。他们只有一百个,而诚实值得这点等待。

Schema 漂移测试抓住代码审查漏掉的 Bug

还有一种测试,效果出奇地好,但几乎没人写。迁移运行完后,断言你的 TypeScript 类型仍然与 schema 匹配。

import { sql } from './db';

it('types match the database schema', async () => {
  const columns = await sql`
    SELECT column_name, data_type, is_nullable
    FROM information_schema.columns
    WHERE table_name = 'users'
  `;

  const userColumns = new Map(columns.map((c) => [c.column_name, c]));

  // If someone adds a NOT NULL column without updating the type, this fails
  expect(userColumns.get('email').is_nullable).toBe('NO');
  expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});

这个测试能抓住那种「迁移给某列加了 NOT NULL,但你的 TypeScript 还认为它是可选的」的情况。手动维护很繁琐,所以才有了 kysely-codegendrizzle-kit generate 这类工具。但哪怕只写一个小规模的手动版本,也能帮你避免一次生产事故。

常见问题

我该删掉所有 Mock 数据库测试吗?

不。保留 Mock 用于数据库无关的纯逻辑测试,比如测试服务是否正确格式化查询字符串。在持久化行为本身是重点的地方,把它们替换成真实数据库测试。

测试中如何处理迁移?

先在模板数据库上跑一次迁移,然后克隆。如果迁移很快,也可以每个测试套件跑一次。绝不要在 CI 里跳过迁移,然后祈祷 schema 匹配。

读从库怎么办?

如果你的应用写主库、读从库,测试也应该这样做。在 Testcontainers 里起两个连接,按照生产环境的方式配置路由。由延迟导致的脏读是真实的 Bug。

这也适用于 NoSQL 数据库吗?

是的。原则相同。Mock 的 MongoDB 客户端不会告诉你,你的复合索引是否支持你以为它能支持的查询。真实的客户端会。

今天就写一个往返测试

选一个你最常修改的仓库方法。删掉它的 Mock。写一个测试:创建一行数据,读回来,断言字段一致。计时。如果耗时不到 50ms,再写一个。

大多数团队发现,真实数据库测试并不是套件慢的元凶。真正的原因通常是:为单元测试起一个无头浏览器、在 CI 里调用真实的支付 API、或者不并行执行测试文件。数据库测试是诚实的、足够快的,而且能抓住那些让你睡不着的 Bug。