
用 Testcontainers 测试数据访问代码
来源标题:Data Access Testing with Testcontainers
文章探讨了在 AI 大量生成代码的背景下如何测试数据访问代码。传统单测引入外部数据库会牺牲速度与隔离性,共享测试库加事务回滚的方案复杂且易并发冲突。作者主张用 Testcontainers 为每个测试提供独立的容器化数据库,并讨论了基础 CRUD 代码是否值得单测。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
如今 AI 已经替我们写掉了绝大多数代码,测试与验证变得前所未有地重要。我们大概都知道怎么用 Jest 或 Vitest 写单元测试,甚至知道用 RTL 之类的东西验证前端代码行为是否正确。
但针对数据访问代码写测试一直更难。单元测试应当快速且隔离,而引入外部数据库与这两个目标都相悖。
过去一种常见做法是准备一个测试数据库,每个测试里加一个 try 块并开启事务(视数据库和测试而定,可能还要用 READ UNCOMMITTED 隔离级别),插入数据、验证,然后在 finally 块里回滚事务。这种做法的缺点显而易见:复杂度高,而且多个并发测试跑在同一个测试数据库上时相互干扰的可能性同样很高。
你真的需要对数据库代码做单元测试吗?
这其实是个非常合理的问题,答案也一如既往是「视情况而定」。如果你的数据访问代码只是基础的 CRUD——插入记录再读回来——那大概率不必为这种低复杂度的代码费心写测试。
但如果你的数据库逻辑确实在做实事,那就考虑用测试来验证它行为正确。本文会告诉你怎么做。
Docker 来救场
如今我们有了比在事务上打补丁更好的选择:Docker。Docker 让我们能在完全隔离的容器里启动几乎任何东西,想干什么就干什么,然后让容器消失得无影无踪。而为了帮助把这个用例落到 Docker 上,有一组相当好用的包,叫 @testcontainers。这个家族里有多个模块,本文我们来看 @testcontainers/postgresql。
前置要求
你需要在本地运行 Docker。如果还没装,请安装(并启动)Docker Desktop(或合适的替代品)。如果你担心 CI/CD,不必担心。GitHub Actions 的 Ubuntu runner 自带 Docker,所以这些测试在那里跑完全没问题。
搭建数据库
你多半会借助自己惯用的 agent 来做这件事,但要知道,搭建数据库就是这么简单:
const postgres = await new PostgreSqlContainer("postgres:18-alpine").start();Code language: TypeScript (typescript)
拿到该容器中数据库的 URL 同样简单:
postgres.getConnectionUri();Code language: TypeScript (typescript)
你大概会想在测试的 beforeAll 钩子里访问这个对象来做初始化,也在 afterAll 钩子里访问它来做清理。要在赋值前声明这个变量又不丢失类型,稍微借助一下 TypeScript 就行:
let postgres: Awaited<ReturnType<PostgreSqlContainer["start"]>>;Code language: TypeScript (typescript)
清理数据库
为此,下面是我们如何在 afterAll 钩子里清理一切:
afterAll(async () => { try { await db.$client.end(); await postgres.stop(); } catch {} });Code language: TypeScript (typescript)
铺设数据
我们希望针对复杂的应用查询、插入逻辑等运行测试。我们有了一个指向 Docker 容器中空 Postgres 数据库的连接,但在写任何测试之前,需要先建好 schema。做法有很多,但如果你用的是 Drizzle,那就格外简单。
drizzle-kit CLI 提供了一种无缝执行数据库迁移的方式。我在这里写过 drizzle-kit,想更全面了解可以看看那篇文章,而 drizzle-kit push 会把我 Drizzle 对象里的数据库结构推送到数据库。稍加代码,我就能用这条命令在容器里的 Postgres 数据库上执行这次迁移。
import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); export async function pushSchema(databaseUrl: string) { try { await execFileAsync("./node_modules/.bin/drizzle-kit", ["push", "--config=drizzle.config.ts", "--force"], { cwd: process.cwd(), env: { ...process.env, POSTGRES: databaseUrl, }); } catch (error) { console.error(error); throw error; }Code language: TypeScript (typescript)
关键在于这一行:
POSTGRES: databaseUrl,Code language: TypeScript (typescript)
我的 Drizzle 配置文件从同名环境变量读取连接字符串:
import { defineConfig } from "drizzle-kit"; const connectionString = process.env.POSTGRES!; export default defineConfig({ dialect: "postgresql", schema: "./src/drizzle/schema.ts", dbCredentials: { url: connectionString, out: "./src/drizzle", });Code language: TypeScript (typescript)
于是我们可以这样简单地调用它:
postgres = await new PostgreSqlContainer("postgres:18-alpine").start(); await pushSchema(postgres.getConnectionUri());Code language: TypeScript (typescript)
我们的用例
我最近在折腾一个健身追踪应用。训练记录的存储结构是完全规范化的。一次训练包含一个或多个片段。每个片段包含一个或多个动作。每个动作包含一个或多个测量值,记录你做了多少次、用了多大重量等等。
插入一次训练的函数接近 100 行。下面是其中的一小段:
export const insertWorkout = async (db: DB, input: WorkoutState, userId: string) => {
const exerciseIds = Array.from(new Set(input.segments.flatMap(segment => segment.exercises.map(exercise => exercise.exerciseId))));
if (exerciseIds.length > 0) {
const [mismatchedExercise] = await db
.select({ securityCheckFailed: sql0 })
.from(exercisesTable)
.where(
exists(
db
.select({ id: exercisesTable.id })
.from(exercisesTable)
.where(and(inArray(exercisesTable.id, exerciseIds), not(eq(exercisesTable.userId, userId)))),
),
);
if (mismatchedExercise != null) {
throw new Error("One or more exercises were not found.");
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。