如果你在测试中 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-codegen 或 drizzle-kit generate 这类工具。但哪怕只写一个小规模的手动版本,也能帮你避免一次生产事故。
常见问题
我该删掉所有 Mock 数据库测试吗?
不。保留 Mock 用于数据库无关的纯逻辑测试,比如测试服务是否正确格式化查询字符串。在持久化行为本身是重点的地方,把它们替换成真实数据库测试。
测试中如何处理迁移?
先在模板数据库上跑一次迁移,然后克隆。如果迁移很快,也可以每个测试套件跑一次。绝不要在 CI 里跳过迁移,然后祈祷 schema 匹配。
读从库怎么办?
如果你的应用写主库、读从库,测试也应该这样做。在 Testcontainers 里起两个连接,按照生产环境的方式配置路由。由延迟导致的脏读是真实的 Bug。
这也适用于 NoSQL 数据库吗?
是的。原则相同。Mock 的 MongoDB 客户端不会告诉你,你的复合索引是否支持你以为它能支持的查询。真实的客户端会。
今天就写一个往返测试
选一个你最常修改的仓库方法。删掉它的 Mock。写一个测试:创建一行数据,读回来,断言字段一致。计时。如果耗时不到 50ms,再写一个。
大多数团队发现,真实数据库测试并不是套件慢的元凶。真正的原因通常是:为单元测试起一个无头浏览器、在 CI 里调用真实的支付 API、或者不并行执行测试文件。数据库测试是诚实的、足够快的,而且能抓住那些让你睡不着的 Bug。