本节目标:让测试从「假设依赖是对的」变成「真的验证依赖」。读完后,你会知道单元测试的边界在哪,能用 Testcontainers 在本地与 CI 里拉起真实的 PostgreSQL、Redis 容器,能用
globalSetup复用容器、用schema隔离并发数据,还能识别「连接被提前关闭」「钩子超时」这类集成测试特有的报错。
4.2 集成测试与 Testcontainers
上一节我们把所有外部依赖都换成了替身:db.order.create 是假的,支付网关是假的。这让测试跑得飞快,也让测试变得脆弱地乐观——它验证的是「我以为数据库会这样表现」,而不是「数据库真的这样表现」。
一个真实例子:OrderRepository.save 在 mock 里永远成功。可真实表上有 UNIQUE(order_id) 约束,第二次调用会抛 P2002;还有 total 列是 NUMERIC(12,2),传 199.999 会被静默舍入。这些行为在 mock 里一概不存在,于是测试全绿,线上 500。
4.2.1 mock 越多,失真越严重
集成测试的定义很简单:不替身掉外部依赖,让被测代码与真实的数据库、缓存、消息队列交互。它要回答的问题和单元测试完全不同:
| 问题 | 单元测试 | 集成测试 |
|---|---|---|
| 业务分支对不对 | ✅ 最擅长 | 顺带覆盖 |
| SQL 能否通过真实约束(唯一键、外键、类型精度) | ❌ 测不到 | ✅ |
| ORM 映射与事务语义是否正确 | ❌ 测不到 | ✅ |
| 连接池、超时、重试配置是否可用 | ❌ 测不到 | ✅ |
| 序列化/反序列化在真实协议下是否一致 | ❌ 测不到 | ✅ |
反过来说,集成测试不擅长覆盖分支:一个 if 的两种走法要跑两遍真实数据库,成本远高于单元测试。所以两者不是替代关系,而是分工。
判断标准可以简化成一句话:只要你的 mock 在「模拟行为」,而不是「提供接口形状」,就该考虑换成集成测试。像 vi.fn().mockResolvedValue({ ok: true }) 这种把整个返回值编出来的替身,就是典型信号。
4.2.2 为什么用 Testcontainers,而不是 docker-compose
常见的替代方案有三类,各有代价:
| 方案 | 问题 |
|---|---|
本地常驻服务(自己 docker run 一个 Postgres) | 版本靠人记,同事机器上数据被改得面目全非,CI 里没有 |
docker-compose up 后跑测试 | 端口固定会冲突,数据不隔离,测试与 compose 生命周期不同步 |
| 用 SQLite / 内存数据库替代 | 方言不同,NUMERIC、jsonb、窗口函数全都不一致,测了个假数据库 |
Testcontainers 的定位是把「临时依赖」变成测试代码的一部分:在测试启动时用 Docker 拉起一个随机端口的容器,测试结束就销毁。它的三条设计正好命中上面的痛点——随机端口(不冲突)、每次全新(不脏数据)、由测试代码控制生命周期(不依赖外部编排)。
它还有两个工程上的好处:镜像版本写在代码里,与 CI 用同一份配置;容器镜像可以走缓存,第二次以后启动只要几百毫秒。想对比 compose 与编程式容器的取舍,可以延伸阅读 Docker Compose 使用指南 与 数据库容器化实践 。
这套思路不是 Node.js 独有:Java 生态里 Testcontainers 已是标配(见 Java Testcontainers 集成测试 ),.NET 也有对应实现(见 C# Testcontainers 集成测试 )。跨语言的一致模式说明它是被反复验证过的方案。
4.2.3 安装与第一个 PostgreSQL 容器
pnpm add -D testcontainers @testcontainers/postgresql @testcontainers/redis execa
先写一个最小可用的测试文件。注意钩子超时:Vitest 默认每个钩子 10 秒,而拉镜像加启动数据库常常超过这个值,必须显式传第三个参数:
// tests/order-repo.integration.test.ts
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
let container: StartedPostgreSqlContainer;
let repo: OrderRepository;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("app")
.withUsername("app")
.withPassword("secret")
.start();
repo = await createRepo(container.getConnectionUri());
}, 120_000);
afterAll(async () => {
await container.stop();
});
describe("OrderRepository", () => {
it("同一 order_id 重复写入会被唯一约束拒绝", async () => {
await repo.save({ id: "o-1", total: 199, paid: true });
await expect(repo.save({ id: "o-1", total: 199, paid: true }))
.rejects.toMatchObject({ code: "23505" });
});
});
getConnectionUri() 返回的是 postgresql://app:secret@localhost:54321/app 这样的地址,端口是随机的,所以同一台机器上并行跑多个测试文件也不会撞车。这是它比 docker-compose 好用的核心原因之一。
4.2.4 用 globalSetup 让容器只启动一次
上面的写法有个明显问题:每个测试文件都会拉一个新容器。10 个文件就是 10 次启动,几十秒白白浪费。正确做法是把容器提升到 globalSetup,全进程只起一次:
// tests/global-setup.ts
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import type { GlobalSetupContext } from "vitest/node";
let container: StartedPostgreSqlContainer;
export async function setup({ provide }: GlobalSetupContext) {
container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("app")
.withUsername("app")
.withPassword("secret")
.start();
provide("databaseUrl", container.getConnectionUri());
}
export async function teardown() {
await container?.stop();
}
在 vitest.config.ts 里注册它,并把集成测试与单元测试分成两个 project(否则单元测试也要等容器):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
{
test: {
name: "unit",
include: ["src/**/*.test.ts"],
exclude: ["**/*.integration.test.ts"],
},
},
{
test: {
name: "integration",
include: ["tests/**/*.integration.test.ts"],
globalSetup: ["./tests/global-setup.ts"],
testTimeout: 30_000,
},
},
],
},
});
测试文件里用 inject 取回地址,并为它补上类型声明:
import { inject } from "vitest";
declare module "vitest" {
export interface ProvidedContext {
databaseUrl: string;
}
}
const databaseUrl = inject("databaseUrl");
这样一来,本地跑 pnpm test 只跑单元测试(秒级),跑 pnpm test:integration 才拉起容器。CI 里两个都跑。
4.2.5 等待策略:容器「起来了」不等于「能连了」
start() 返回时容器进程已经在跑,但数据库可能还在做初始化。Testcontainers 默认的等待策略是「容器内日志出现某行」或「端口可连接」,对 PostgreSQL 通常够用。但换成 Kafka、Elasticsearch 这类启动慢的组件就必须显式配置:
import { KafkaContainer, type StartedKafkaContainer } from "@testcontainers/kafka";
const kafka: StartedKafkaContainer = await new KafkaContainer("confluentinc/cp-kafka:7.6.0")
.withStartupTimeout(180_000)
.start();
withStartupTimeout 只控制等待时长,真正的判定条件由等待策略决定。判断是否踩到这个坑的信号很典型:测试偶发失败、重跑就好,报错是 Connection terminated unexpectedly 或 ECONNREFUSED。这类「flake」几乎都源于等待策略不足,而不是业务代码有 bug。消息中间件的容器化测试模式可延伸阅读 Kafka 集成测试与 Testcontainers
。
4.2.6 数据隔离:每个 worker 一个 schema
globalSetup 解决了容器数量,但引入一个新问题:所有测试文件共用一个数据库,并行跑时会互相污染。Vitest 默认按 CPU 核数开多个 worker 进程,两个文件同时 INSERT INTO orders (id) VALUES ('o-1') 就会有一个失败。
最省事的方案是按 worker 分配独立 schema。Vitest 会为每个 worker 注入 VITEST_POOL_ID:
import { beforeEach, beforeAll } from "vitest";
import { sql } from "drizzle-orm";
const schemaName = `test_${process.env.VITEST_POOL_ID ?? "0"}`;
beforeAll(async () => {
await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`));
await db.execute(sql.raw(`SET search_path TO ${schemaName}`));
});
beforeEach(async () => {
// 每个用例前清空本 schema 下的表,保证用例互不影响
await db.execute(sql.raw(`TRUNCATE orders, order_items RESTART IDENTITY CASCADE`));
});
注意 SET search_path 是连接级的:如果用连接池,必须在每次取连接时重设,或者干脆把 schema 写进连接串的 options 参数。更稳妥的做法是用 ?options=-csearch_path%3D${schemaName} 拼进 URI,让每个连接天然落在自己的 schema 里。
另一种常见策略是每个用例包在一个事务里、结束时回滚。它更快,但要求被测代码不自己开事务、不跨连接——一旦用了 SERIALIZABLE 或者业务里显式 BEGIN,这套就失效了。我的建议是:默认用 schema 隔离 + TRUNCATE,只有确知边界时才用事务回滚。
4.2.7 在容器里跑 Prisma 迁移
集成测试的价值在于验证真实 schema,所以不能手工建表——必须跑和线上同一套迁移。以 Prisma 为例(schema 与类型生成见 7.1 Prisma schema 与类型生成 ):
// tests/global-setup.ts(节选)
import { execa } from "execa";
export async function setup({ provide }: GlobalSetupContext) {
container = await new PostgreSqlContainer("postgres:16-alpine").start();
const url = container.getConnectionUri();
// 用生产同一套迁移建表,而不是 db push
await execa("pnpm", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: url },
});
provide("databaseUrl", url);
}
这里有一个非常容易踩的坑:Prisma Client 在构造时读取 DATABASE_URL。如果你的测试文件在模块顶层写了 const prisma = new PrismaClient(),那么它在 beforeAll 拿到容器地址之前就已经用旧值初始化完毕了。表现是测试连到了本机默认的 5432 而不是容器。
修复有两种:把 DATABASE_URL 通过 Vitest 的 env 配置注入,或者把 PrismaClient 的创建延迟到 beforeAll 里、再用 datasourceUrl 显式传入:
const prisma = new PrismaClient({ datasourceUrl: inject("databaseUrl") });
4.2.8 Redis 与其他依赖
Redis 的接法几乎一样,容器类型换成 RedisContainer:
import { RedisContainer, type StartedRedisContainer } from "@testcontainers/redis";
const redis = await new RedisContainer("redis:7-alpine").start();
const client = createClient({ url: redis.getConnectionUrl() });
await client.connect();
多依赖时可以并行启动,把总耗时压到最慢的那个:
const [pg, cache] = await Promise.all([
new PostgreSqlContainer("postgres:16-alpine").start(),
new RedisContainer("redis:7-alpine").start(),
]);
真实 Redis 能测出 mock 测不到的东西:SETEX 的过期精度、INCR 的原子性、Lua 脚本在 EVAL 下的行为。这些恰恰是缓存层最容易出错的地方,类型安全封装的做法可参考 8.2 Redis 类型安全封装
。
4.2.9 CI 里的三个差异
本地能跑不代表流水线能跑,差异集中在三处:
| 差异点 | 本地 | CI | 处理方式 |
|---|---|---|---|
| Docker 可用性 | Docker Desktop 常驻 | 需要挂载 /var/run/docker.sock 或用 DinD | 在 runner 上挂 socket,或用 docker:dind service |
| 镜像拉取 | 已有本地缓存 | 每次从零拉 | 缓存 ~/.cache,或预拉镜像 |
| 资源清理 | 手动 | 必须自动 | 依赖 Ryuk(Testcontainers 的清理侧车容器) |
Ryuk 是 Testcontainers 的一个「收割者」容器:它监控测试进程,进程一退出就删除本次创建的所有容器。在 DinD 环境里 Ryuk 常因权限问题起不来,需要设 TESTCONTAINERS_RYUK_DISABLED=true 并自行在 job 结束时清理。忘了这一步的后果是 runner 磁盘被慢慢吃满,几周后开始随机失败。
另外,withReuse() 能让容器在测试结束后保留,下次启动直接复用,本地开发时体验很好;但它会带来状态残留,CI 里千万不要开。
4.2.10 与 E2E 的边界
集成测试再真实,也测不到「浏览器里点按钮 → 前端发请求 → 后端落库 → 页面更新」这条完整链路。那属于端到端测试,用 Playwright 之类工具做(见 E2E 测试与 Playwright )。
三层的分工可以这样记:
- 单元测试:业务分支、边界条件、纯计算。毫秒级,数量最多。
- 集成测试:真实依赖的契约与配置。秒级,覆盖关键路径。
- E2E:用户可见的完整流程。分钟级,只保留几条核心用例。
不要在集成测试里写几十个分支组合——那是单元测试的活;也不要指望 E2E 覆盖异常路径——那成本高到没人愿意维护。
4.2.11 常见报错速查
| 报错信息 | 原因 | 修复 |
|---|---|---|
Could not find a working container runtime strategy | 本机没装/没启动 Docker | 启动 Docker Desktop 或安装 colima |
Hook timed out in 10000ms | 钩子超时太短 | 给 beforeAll 传第三个参数,或设 testTimeout |
Connection terminated unexpectedly | 容器尚未就绪,或已被 stop() | 检查等待策略与 afterAll 顺序 |
ECONNREFUSED ::1:5432 | Prisma/驱动连到了本机默认端口 | 确认 DATABASE_URL 在 Client 构造前已注入 |
duplicate key value violates unique constraint | 并行 worker 共用同一 schema | 按 VITEST_POOL_ID 隔离 schema |
小结
这一节我们把「依赖」从想象换成了真实:
- 边界认知:mock 只能验证「我以为的行为」,凡是要验证约束、事务、协议一致性的地方,都必须上真实依赖。
- 容器化依赖:Testcontainers 用随机端口、一次性容器解决了版本、端口、脏数据三个老问题,且跨语言通用。
- 生命周期:容器放进
globalSetup只启动一次,用provide/inject传递连接串,单元测试与集成测试拆成两个 project。 - 数据隔离:按
VITEST_POOL_ID分配独立 schema,用例间用TRUNCATE清表;事务回滚只在确知边界时使用。 - CI 差异:Docker socket、镜像缓存、Ryuk 清理三件事必须显式处理,否则会变成随机失败的来源。
现在我们的测试既能验证业务逻辑,也能验证真实依赖,但还有一个维度没覆盖:类型本身。如果 OrderService.checkout 的返回类型从 string 悄悄变成 Promise<string | null>,所有运行时测试都可能照常通过,只有调用方在编译时炸掉。下一节 4.3 类型测试与覆盖率门禁
就来补上这一层,并把这些测试接入 CI 门禁。若想先把配置的类型化做扎实,可以回看 2.2 环境变量与配置的类型化
,因为集成测试最常出错的地方正是连接配置。
阅读导航:上一节:4.1 Vitest 单元测试 · 下一节:4.3 类型测试与覆盖率门禁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。