接手一个用 LoopBack 4 写的服务端项目,数据访问层往往是最容易出问题,也最容易含糊带过的地方:模型定义写得漂亮,控制器也测了,偏偏仓库(Repository)那段代码没人敢保证它在真实 MySQL 上跑起来是什么样。这篇内容就是我围绕LoopBack 4 MySQL 仓库验收测试这件事的一次完整梳理,把本机实战、远程实例、Docker 容器三条运行路线全部跑一遍,再把背后的原理和踩过的坑摊开讲。适合已经能写出 CRUD 仓库、但对“怎么证明它在真库上确实对”心里没底的中级开发者,也适合想把数据库测试塞进流水线却一直被环境问题绊住的同学。读完你应该能自己搭出一套稳定、可并行、不污染数据、跑完就清干净的仓库层验收测试。
1. 仓库层为什么必须拿真库做验收
1.1 单元测试和验收测试的边界到底在哪
先说清一个概念问题,因为很多人把这两件事混着做,最后两边都没做扎实。单元测试针对的是纯逻辑,比如一个把 DTO 转成实体的小函数、一段计算分页偏移量的表达式,这些东西不碰 IO,跑起来毫秒级,错了定位也快。而验收测试针对的是“协作”,是仓库方法加上连接器、加上真实数据库引擎、加上 SQL 方言之后产生的那一坨行为。这坨行为里有一大半是你在 TypeScript 层面看不见的:索引有没有生效、外键约束触发时的错误码是什么、自增主键在并发插入下会不会跳号、LIMIT/OFFSET拼出来的 SQL 在深分页时会不会扫全表。
我在实际项目里的划分原则很粗暴:只要一行代码最终会变成 SQL 发出去,它就该有一条验收测试兜着。repository.create()、repository.find({where: ...})、repository.updateAll()这些都属于此列。反过来,模型上的@property装饰器校验、@hasMany关系定义本身属于静态声明,单元测试甚至类型检查就能覆盖,不必浪费真库连接。
这么划分的好处是测试金字塔不倒。验收测试天然慢,一条用例几百毫秒到几秒,你要是把校验逻辑也塞进去,整个测试集跑一遍十分钟,本地开发根本没人愿意跑,最后就变成 CI 上红了一次没人管、两次没人管、三次直接注释掉。慢测试必须少而精,这是纪律。
1.2 内存连接器替代不了 MySQL 的五件事
LoopBack 官方文档里演示仓库测试,经常用内存连接器(in-memory connector)糊弄过去。我理解这个做法在教学场景下的便利,但真拿它当验收手段,等于自己骗自己。下面这五件事是我实际对比过的差异,每一条都可能让线上翻车。
| 差异点 | 内存连接器表现 | 真实 MySQL 表现 |
|---|---|---|
| 唯一约束 | 基本不生效或行为随意 | 抛ER_DUP_ENTRY(错误码 1062) |
| 字段长度 | 不校验,随便存 | 严格模式下直接报错,非严格模式静默截断 |
| 排序规则 | 按 JS 字符串比较 | 受collation影响,大小写、重音都可能不同 |
| 事务 | 无真正隔离级别 | REPEATABLE READ下的快照读、间隙锁都会显现 |
| 时间类型 | 就是 JS Date | 受time_zone影响,跨时区读写会漂移 |
我印象最深的是第三条。有次做一个按名称模糊搜索的功能,内存测试全绿,上线后用户反馈“搜不出来”。查了半天才发现 MySQL 表用的排序规则对某些字符做了归一化处理,LIKE '%xxx%'的匹配范围和 JS 的includes完全不是一回事。这种问题你在内存连接器上永远测不出来,因为差异不在代码里,在引擎里。
还有第五条也特别隐蔽。MySQL 的DATETIME不带时区信息,读写时驱动会按连接参数timezone做转换。内存连接器存的是原生Date对象,压根没有这个转换环节。等你把数据搬到真库,凌晨两点跑批的时候发现时间戳集体偏移八小时,那才叫难受。
我的建议很直接:内存连接器只用来跑完全不涉及查询语义的纯逻辑测试,比如仓库的钩子函数(
@beforeSave之类)里做的数据加工。凡是涉及 SQL 行为的,一律上真库。
2. 本机 MySQL 路线:最快跑通的第一条路
2.1 建库建账号与最小权限配置
本机装一个 MySQL 是最省事的起点,尤其对刚接触这块的人来说,能直接在客户端里看表、看数据,调试体感最好。但我强烈反对用root账号跑测试,理由不是安全洁癖,而是权限过大会掩盖问题。root有DROP DATABASE权限,automigrate一不小心把库删了你都不知道;普通账号没权限时反而会立刻报错,帮你提前发现危险操作。
建库和账号的 SQL 大概是这样:
CREATE DATABASE IF NOT EXISTS lb4_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS 'lb4_test'@'%' IDENTIFIED BY 'lb4_pass'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, REFERENCES ON lb4_test.* TO 'lb4_test'@'%'; FLUSH PRIVILEGES;注意我给的权限里有CREATE、DROP、INDEX、ALTER,这几个是必须的,因为测试要反复建表删表。但故意没给CREATE USER、GRANT OPTION、SUPER,也没有*.*这种全局授权。REFERENCES这个权限容易被漏掉,一旦模型里定义了外键关系,没有它建表会失败,报的错还特别晦涩。
字符集一定要显式指定utf8mb4。我见过好几个项目建库时图省事用了默认值,后来存表情符号或者某些中日韩生僻字直接乱码,回头改表、改列、改排序规则,数据迁移能把人折腾一周。测试库和正式库的字符集必须完全一致,否则测试通过不代表线上能存。
还有个小细节:
utf8mb4_unicode_ci和utf8mb4_0900_ai_ci(MySQL 8 默认)在比较行为上有差异。如果测试库和线上库排序规则不同,某些边界用例(大小写敏感、重音字符)的结论会不一致。我的做法是以线上库为基准,测试库一模一样照抄,别让引擎版本自己决定。
2.2 测试专用数据源怎么和业务数据源解耦
LoopBack 4 的数据源(DataSource)是通过依赖注入管理的,业务代码里的仓库注入的是应用启动时注册的那个数据源。如果测试直接复用这个数据源,问题就来了:测试环境需要不同的数据库名、不同的连接池大小、甚至不同主机,你不能把这些测试专用配置写进生产配置文件里。
我的做法是让数据源配置优先从环境变量读,代码里只留兜底默认值:
// src/datasources/mysql.datasource.ts import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; import {juggler} from '@loopback/repository'; const config = { name: 'mysql', connector: 'mysql', host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3306), user: process.env.DB_USER ?? 'lb4_test', password: process.env.DB_PASSWORD ?? 'lb4_pass', database: process.env.DB_NAME ?? 'lb4_test', connectionLimit: 10, connectTimeout: 10000, charset: 'utf8mb4', timezone: '+08:00', }; @lifeCycleObserver('datasource') export class MysqlDataSource extends juggler.DataSource implements LifeCycleObserver { static dataSourceName = 'mysql'; static readonly defaultConfig = config; constructor( @inject('datasources.config.mysql', {optional: true}) dsConfig: object = config, ) { super(dsConfig); } }然后在测试文件里,我不走依赖注入,直接new一个实例出来,把配置覆盖掉:
import {MysqlDataSource} from '../../src/datasources'; import {UserRepository} from '../../src/repositories'; const ds = new MysqlDataSource({ ...MysqlDataSource.defaultConfig, database: process.env.DB_NAME ?? 'lb4_test', connectionLimit: 4, });这里的关键判断是:仓库测试不一定要走完整的 Application 启动流程。很多人习惯先new Application(),再app.boot(),然后从容器里app.getRepository(...)。这么写在需要测 HTTP 层的时候是必须的,但只测仓库的话太重了。每个测试文件启动一次完整应用,几秒就没了,十个文件就是几十秒。直接new数据源、new仓库,依赖关系清晰,启动开销几乎为零。
2.3 automigrate 的触发时机与代价
建表这件事在真实项目里是迁移脚本干的,但在测试里通常走automigrate()。这个方法的实现是:先DROP TABLE IF EXISTS,再按模型定义CREATE TABLE,顺带把所有索引、外键都建出来。干净、确定、可重复,非常适合测试。
before(async () => { await ds.connect(); await ds.automigrate(['user', 'order', 'order_item']); });传数组的时候,LoopBack 会自动处理模型之间的外键依赖顺序,你不需要自己排。这点做得挺贴心。但有两个代价必须认清。
第一个代价是它只认模型,不认历史迁移。如果线上表结构经历过多次 ALTER,和模型定义已经产生了偏差(常见于列长度改过、索引加过又删过),automigrate建出来的表跟线上不是同一个东西,测试结论的可信度就要打折。我的应对方式是维护一份对照检查:定期拿线上SHOW CREATE TABLE的结果和自己automigrate出来的结果比一比,发现差异就去补迁移脚本或者修模型定义。这个动作很枯燥,但它能筛掉一类最隐蔽的“测试全绿、线上报错”。
第二个代价是它真的很慢。每次automigrate都是若干次 DDL,MySQL 的 DDL 不像 DML 那么轻量。如果你的测试文件有二十个,每个before都跑一遍automigrate,光建表就能耗掉半分钟。我的优化做法是:只在第一个测试文件里做一次automigrate,后续文件复用同一套表,靠deleteAll()清数据。
// test/setup/migrate.ts 在全局 setup 里跑一次 export default async function globalSetup() { const ds = new MysqlDataSource(MysqlDataSource.defaultConfig); await ds.connect(); await ds.automigrate(['user', 'order', 'order_item']); await ds.disconnect(); }对应地把 jest 配置加上globalSetup,各测试文件里就不要automigrate了,只留清数据。这一改动在我手上的项目里把全量测试从 90 秒压到了 35 秒左右,收益相当明显。
提醒一句:
automigrate千万别在有数据的库上跑,它真的是先删表。生产环境的迁移请用专门的迁移工具,automigrate只配出现在测试里,而且建议在环境变量里加个断言,比如if (process.env.NODE_ENV === 'production') throw new Error(...),防止哪天脚本被人误执行。
3. 远程实例路线:连接参数、超时与并行隔离
3.1 连接池上限该怎么算才不炸
本机跑顺了之后,下一步往往是接入共享的远程 MySQL 实例,不管是自建还是托管服务。这条路最大的变化是你不独占数据库,连接数、超时、隔离都成了需要精打细算的资源。
先说连接池。connectionLimit这个参数设多少合适,不能拍脑袋。有个可以照着算的公式:
峰值连接数 ≈ 并行测试进程数 × 每个进程的数据源实例数 × connectionLimit
举例,假设你用 jest 默认的并行策略,8 个 worker 同时跑,每个 worker 里有 3 个测试文件各自new了一个数据源(虽然理论上一个 worker 里文件是串行执行的,但如果你没显式断开连接,连接会一直挂着),connectionLimit设 10,那理论上峰值就是 240 个连接。而 MySQL 默认max_connections是 151,直接打爆,报错是ER_CON_COUNT_ERROR,错误码 1040,提示信息Too many connections。
我的实际配置是:8 个 worker、每个 worker 最多 1 个活跃数据源、connectionLimit给 4,峰值 32 个连接,给其他应用留足余量。测试场景下的并发度本来就不高,4 个连接完全够用,反而能更快暴露“连接泄露”问题——如果你的代码里有查询没释放连接,小池子会立刻堵死报错,大池子则会把故障拖到运行时。
const ds = new MysqlDataSource({ ...MysqlDataSource.defaultConfig, connectionLimit: 4, connectTimeout: 8000, acquireTimeout: 8000, });connectTimeout和acquireTimeout这两个也值得单独说。前者是建立 TCP 连接的超时,后者是从池子里取连接的超时。默认值一个是 10 秒,另一个可能根本没有限制。在 CI 环境里,如果数据库容器还没起来,测试就会卡在那个“取连接”上直到超时,日志里啥也看不出来,只看到一个莫名其妙的 30 秒超时。所以我会把这两个都压到 8 秒,并且保证在before里显式await ds.connect(),让失败点明确。
3.2 用独立 schema 隔离解决并行冲突
并行测试最容易踩的坑是数据互相干扰。A 文件插了一条email = 'a@example.com',B 文件也插同一条,唯一索引一冲突,两个文件的结论都不可信了。deleteAll()只能解决顺序冲突,解决不了时间重叠的冲突。
最省心的隔离方案是给每个 worker 分配独立的库。思路是全局 setup 阶段先建好 N 个库,测试运行时按JEST_WORKER_ID选库:
// test/global-setup.ts import mysql from 'mysql'; export default async function globalSetup() { const workers = Number(process.env.LB4_TEST_WORKERS ?? 4); const conn = mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3306), user: process.env.DB_ROOT_USER ?? 'root', password: process.env.DB_ROOT_PASSWORD ?? '', multipleStatements: true, }); await new Promise<void>((resolve, reject) => { conn.connect(err => (err ? reject(err) : resolve())); }); for (let i = 1; i <= workers; i++) { const name = `lb4_test_${i}`; await new Promise<void>((resolve, reject) => { conn.query( `CREATE DATABASE IF NOT EXISTS \`${name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, err => (err ? reject(err) : resolve()), ); }); } conn.end(); }测试文件里取库名:
const dbName = `lb4_test_${process.env.JEST_WORKER_ID ?? '0'}`;注意全局 setup 用的是高权限账号(因为要建库),而测试运行用低权限账号(只能操作自己那个库)。这个权限分层很关键,它同时解决了隔离和安全两个问题。
JEST_WORKER_ID从 1 开始编号,不是从 0。我第一次写的时候按 0 开始建库,结果所有 worker 挤在同一个库上,冲突照旧,还多花了半小时才反应过来。这种小坑看文档能躲,但很多人不看。
3.3 时区、字符集与排序规则三件套
远程实例最让人抓狂的三类差异,我统一叫它们“三件套”。这三样只要有一项对不上,测试结论和线上行为就可能分叉。
时区。MySQL 连接参数里的timezone决定驱动如何把 JS 的Date转成字符串、又如何把字符串转回Date。常见写法是'+08:00'或者'Z'(UTC)。如果测试库设成东八区、线上库设成 UTC,同一个new Date()存进去再读出来,差八小时。我的习惯是连接参数和 MySQL 服务端time_zone变量都显式设成同一值,绝不依赖默认。
字符集。除了库、表级别的utf8mb4,连接级别也要设。驱动参数里那个charset就是干这个的,别省。我见过连接字符集用了latin1、写中文全是问号的案例,排查了半天还以为是代码编码问题。
排序规则。影响ORDER BY、WHERE name = ?、LIKE。utf8mb4_general_ci和utf8mb4_unicode_ci在重音、特殊字符上行为不同;_ci结尾不区分大小写,_cs或_bin区分。测试库必须和线上库保持同一排序规则,否则会出现“测试里where name = 'ABC'能查到'abc',线上查不到”这种诡异现象。
-- 自检当前会话的实际设置 SELECT @@character_set_connection, @@collation_connection, @@time_zone, @@sql_mode;这条查询我建议你在写第一行测试代码前就跑一次,把输出记下来,然后和线上库对比。差在哪里,一目了然。
4. Docker 路线:一条命令起库并接入流水线
4.1 容器启动参数的正确姿势
Docker 跑 MySQL 的价值在于环境完全可复现。本机装 MySQL 会因为版本、配置、残留数据各有差异,而镜像拉下来就是同一个东西,团队里谁跑结果都一样,流水线上更是一模一样。
最基础的启动命令:
docker run -d \ --name lb4-mysql-test \ -e MYSQL_ROOT_PASSWORD=rootpass \ -e MYSQL_DATABASE=lb4_test \ -e MYSQL_USER=lb4_test \ -e MYSQL_PASSWORD=lb4_pass \ -p 3306:3306 \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci这里有几个参数必须解释清楚为什么加。MYSQL_DATABASE会在容器初始化时自动建库,省掉你手动敲 SQL;MYSQL_USER和MYSQL_PASSWORD会自动建一个对该库有完整权限的普通账号,正好满足前面说的最小权限原则。
关键在最后两行:--character-set-server和--collation-server。它们通过command传给 mysqld,决定服务端默认字符集。不加的话,容器用的就是镜像默认值,某些版本默认可能就是utf8mb4_0900_ai_ci,和服务端环境不一定对得上。显式声明,心里踏实。
还有一个非常出名的兼容性坑:MySQL 8 默认认证插件是caching_sha2_password,而一些老的 MySQL 驱动对它的支持不完整,连接时会报ER_NOT_SUPPORTED_AUTH_MODE。如果你用的连接器版本偏旧,可以加一条启动参数:
--default-authentication-plugin=mysql_native_password这条是权宜之计,长期还是建议把驱动升级到支持新认证方式。但在赶进度的时候,它能让你五分钟内跑起来,先通后优,我觉得没问题。
4.2 等库就绪,别用 sleep 赌运气
容器docker run -d返回的时候,MySQL 往往还没初始化完。它的启动过程是:拉起进程 → 初始化数据目录 → 建库建用户 → 开放端口监听。这个序列在慢机器上可能要二三十秒。很多脚本用sleep 20硬等,结果 CI 机器一卡就失败,重跑一次又过了,典型的 flaky。
正确的做法是探活。MySQL 镜像里自带mysqladmin,用它 ping 一下最直接:
until docker exec lb4-mysql-test \ mysqladmin ping -h 127.0.0.1 -u root -prootpass --silent; do echo "等待 MySQL 就绪..." sleep 2 done注意-h 127.0.0.1不能省。容器里的 socket 连接和 TCP 连接的可用时机不完全同步,用 TCP 探活才代表外部能连上。这个细节坑过不少人,报错是Can't connect to local MySQL server through socket,但端口其实还没监听。
用 compose 的话,直接上healthcheck更优雅:
version: '3.8' services: mysql: image: mysql:8.0 command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: lb4_test MYSQL_USER: lb4_test MYSQL_PASSWORD: lb4_pass ports: - "3306:3306" healthcheck: test: - CMD - mysqladmin - ping - -h - 127.0.0.1 - -u - root - -prootpass interval: 5s timeout: 5s retries: 20 start_period: 20sstart_period这个字段值得单拎出来说。它表示“容器启动后这段时间内失败不计入 retries”。不给的话,MySQL 初始化阶段的前几次探测失败会消耗重试次数,导致明明还在初始化就被判死。设成 20 秒是个比较稳的经验值。
4.3 把它塞进测试脚本和流水线
本地开发和流水线上,跑测试前后的动作应该统一成两条 npm 脚本,别让人记一堆 docker 命令:
{ "scripts": { "db:up": "docker compose -f docker-compose.test.yml up -d --wait", "db:down": "docker compose -f docker-compose.test.yml down -v", "test:db": "npm run db:up && npm test && npm run db:down" } }--wait是 compose 的一个好用选项,它会一直等到所有带healthcheck的服务变成 healthy 才返回,等于帮你把探活逻辑内置了。down -v里的-v是删数据卷,保证下次启动是干净状态——测试要的就是每次都从零开始,不要残留。
在流水线里,如果你的 CI 平台支持 service 容器,把上面那份 compose 里的镜像和参数搬过去就行;如果不支持,就用docker run脚本。两种情况我都跑过,关键是把环境准备和测试执行拆成两个步骤,环境准备失败和测试失败在日志里要能一眼分开,否则出问题的时候根本定位不到是哪一层挂了。
一个实战细节:CI 里跑完测试记得抓容器日志。如果测试失败原因不明,
docker logs lb4-mysql-test里往往能看到死锁、连接拒绝、认证失败这些引擎侧的信息,比测试报告详细得多。我习惯在after_script(或等价的收尾阶段)里无条件 dump 一下,失败时能直接看。
5. 用例编写实录:造数、断言与清理
5.1 生命周期钩子的执行顺序别搞反
一段可靠的仓库测试,骨架在于钩子的顺序。我常用的结构是before建连接、after断连接、beforeEach清数据。顺序反了就是灾难:连接没建好就清数据会报错,连接断太早后面用例全挂。
import {expect} from '@loopback/testlab'; import {MysqlDataSource} from '../../src/datasources'; import {UserRepository} from '../../src/repositories'; describe('UserRepository 验收测试', () => { let ds: MysqlDataSource; let repo: UserRepository; before(async () => { ds = new MysqlDataSource({ ...MysqlDataSource.defaultConfig, database: `lb4_test_${process.env.JEST_WORKER_ID ?? '1'}`, connectionLimit: 4, }); await ds.connect(); repo = new UserRepository(ds); }); after(async () => { await ds.disconnect(); }); beforeEach(async () => { await repo.deleteAll(); }); it('创建后能按主键读回', async () => { const created = await repo.create({name: 'A同学', email: 'a@example.com'}); const found = await repo.findById(created.id); expect(found.email).to.equal('a@example.com'); }); });ds.connect()这行其实是可以省的,连接器是懒加载的,第一次查询时才真正连。但显式写出来有个巨大好处:连接失败会在before阶段立刻暴露,报错信息指向明确的初始化步骤,而不是淹没在第一条用例里。这一点在远程实例和容器环境下尤其重要,因为连接失败是最高频的故障。
after里的disconnect()千万别忘。忘了的后果是 jest 进程挂住不退出,你会看到测试结果都出来了但命令行就是不返回,必须--forceExit。而--forceExit又会掩盖真正的资源泄露问题,属于治标不治本。老老实实断开,让它自然退出。
5.2 约束类用例怎么写才不脆
验收测试里最有价值的是约束验证,最容易写崩的也是它。崩的原因通常是断言方式不对——直接断言错误信息文本。
// 脆弱写法:MySQL 版本一升级,错误文案变了就挂 await expect( repo.create({name: 'B同学', email: 'dup@example.com'}), ).to.be.rejectedWith('Duplicate entry');MySQL 的错误文案在不同版本、不同语言包里都可能变。稳妥的写法是断言错误码:
it('重复邮箱触发唯一约束', async () => { await repo.create({name: 'B同学', email: 'dup@example.com'}); try { await repo.create({name: 'C同学', email: 'dup@example.com'}); throw new Error('预期应该抛出唯一约束错误,但没有'); } catch (err: any) { expect(err.code).to.equal('ER_DUP_ENTRY'); } });错误码ER_DUP_ENTRY是 MySQL 的稳定契约,对应数字 1062,跨版本不会变。同样的思路也适用于外键约束(ER_NO_REFERENCED_ROW_2)、非空约束(ER_BAD_NULL_ERROR)、字段超长(ER_DATA_TOO_LONG)。
这里有个容易被忽略的前提:只有开启了严格模式,字段超长才会报错。MySQL 5.7 之后默认开启STRICT_TRANS_TABLES,但如果你在容器启动参数或配置文件里改过sql_mode,超长字段会静默截断而不是报错,那你的用例就会得到“插入成功”的结果,断言直接失效。所以严格模式这件事也建议在测试初始化时断言一下:
const [rows] = await ds.execute("SELECT @@sql_mode AS mode"); expect(rows[0].mode).to.contain('STRICT_TRANS_TABLES');这行代码看着多余,但它能防止一类“测试在某个环境里悄悄失效”的事故。测试的可信度建立在环境确定的前提上,环境本身也该被测试。
5.3 事务用例与回滚验证
事务行为是内存连接器完全模拟不了的部分,也是我认为最该上真库的一类用例。写事务测试有个常见误区:只在仓库方法上传transaction参数,却忘了验证提交和回滚的实际效果。
it('事务回滚后数据不落库', async () => { const tx = await ds.beginTransaction(); try { await repo.create( {name: 'D同学', email: 'rollback@example.com'}, {transaction: tx}, ); await tx.rollback(); } catch (err) { await tx.rollback(); throw err; } const result = await repo.count(); expect(result.count).to.equal(0); });count()这里读的是新连接(不是事务里的那个),所以它看不到未提交的数据。这个断言才真正验证了“回滚生效”,比断言方法返回值有意义得多。
还有一个更容易被忽视的场景:跨仓库事务。比如创建订单和扣减库存要在一个事务里,如果两个仓库用了不同的数据源实例,事务就串不起来,回滚只能回滚一半。测试应该覆盖这条路径:
it('跨仓库事务回滚时两个仓库都不留数据', async () => { const tx = await ds.beginTransaction(); try { await orderRepo.create({userId: 1, amount: 100}, {transaction: tx}); await stockRepo.updateAll({userId: 1}, {quantity: 99}, {transaction: tx}); throw new Error('模拟业务异常'); } catch (err) { await tx.rollback(); } expect((await orderRepo.count()).count).to.equal(0); expect((await stockRepo.findById(1)).quantity).to.equal(100); });这类用例我在每个用到事务的项目里都会写至少一条。它可以挡住一类非常典型的错误:某个仓库方法内部自己开了新连接,把事务切断了。这种 bug 靠代码审查很难发现,靠测试一跑就露馅。
清理数据时如果表之间有外键,
deleteAll()的顺序会导致ER_ROW_IS_REFERENCED_2。两个解法:按依赖倒序清(先删子表再删父表),或者临时关掉外键检查。我更推荐前者,虽然啰嗦一点,但不会因为忘了恢复检查而污染后续用例。
6. 排错速查:从连接拒绝到死锁重试
6.1 报错对照表
这张表是我这两年积累下来的,遇到报错先对一遍,大部分情况能直接定位:
| 报错信息 / 错误码 | 常见成因 | 处理方向 |
|---|---|---|
ECONNREFUSED | 端口没监听、容器未启动完成 | 加探活,确认-h 127.0.0.1 |
ER_ACCESS_DENIED_ERROR | 账号密码错、Host 不匹配 | 检查user@'%'授权与实际连接源 |
ER_NOT_SUPPORTED_AUTH_MODE | 驱动不支持新认证插件 | 升级驱动或改用旧认证插件 |
ER_CON_COUNT_ERROR(1040) | 连接池开太大、连接泄漏 | 降connectionLimit,检查是否漏disconnect |
ER_DUP_ENTRY(1062) | 唯一索引冲突 | 清数据或改用唯一值造数 |
ER_NO_REFERENCED_ROW_2 | 外键指向的主表记录不存在 | 调整造数顺序 |
ER_LOCK_DEADLOCK(1213) | 并行用例交叉更新同一批行 | 调整更新顺序,或加重试 |
ER_TRUNCATED_WRONG_VALUE | 字符集或时区不一致 | 对齐三件套 |
ER_DATA_TOO_LONG | 字段长度不够 | 检查模型列定义与迁移是否一致 |
ER_LOCK_DEADLOCK这条我想多说两句。它的本质是两条事务以不同顺序锁同一批行,MySQL 检测到环路后主动杀掉其中一个。在并行测试里,这往往不是代码 bug,而是用例并发度过高导致的假阳性。我的处理策略是给测试用的写操作加一层小重试:捕获 1213 错误后等 50 到 200 毫秒重试,最多三次。生产代码里当然要做更严谨的设计,但测试代码里这个策略能显著降低 flaky 率。当然前提是你确认死锁不是真 bug。
6.2 几个反直觉的实操经验
别断言自增 ID 的连续值。很多人喜欢写expect(created.id).to.equal(1)。这条断言在不同环境下随时会挂:deleteAll()不会重置自增计数器,并行测试插入顺序不确定,AUTO_INCREMENT在有回滚的事务里还会跳号。正确做法只断言 ID 存在且为正,需要用具体 ID 时从返回值里取。
updateAll返回的是数量,不是记录。这个在写断言时特别容易犯迷糊,await repo.updateAll({name: 'x'}, {status: 1})返回{count: 3},不是三个实体。想验证更新内容得再查一次。这不是 LoopBack 的怪癖,是 SQLUPDATE的语义,但第一次碰到的人基本都会写错。
连接器版本和数据库版本的组合要锁死。我在一个项目里遇到过mysql连接器某版本对JSON类型字段的处理有 bug,automigrate建出来的列类型和预期不符。后来在package.json里把连接器版本钉死(不用^),并加了一条测试断言SHOW COLUMNS的结果,才算稳住。环境不确定是 flaky 测试最大的来源,能钉死的都钉死。
慢查询日志是最便宜的诊断工具。验收测试失败但错误信息看不懂的时候,去 MySQL 开一下慢查询日志(或者临时把long_query_time设成 0 抓全部 SQL),看看仓库到底发出了什么语句,比读代码猜要快十倍。我现在的习惯是每个新项目第一次跑验收测试时都开着这个日志看一遍,很多“为什么查不到”的问题当场就明了。
清数据比造数据更容易出问题。造数据失败会立刻报错,清数据失败往往是静默污染——某条记录没删掉,下一条用例的唯一索引就冲突了,报错还指向错误的用例。所以我的beforeEach清数据一定要把表列全,而且宁可多删几个表,也别漏。有一个小技巧是启动时先查一遍information_schema.tables拿到所有表名,按外键依赖排序后逐个TRUNCATE,比手写一长串deleteAll()更不容易漏。
我个人的体会是,仓库层验收测试的价值不在于“证明代码是对的”,而在于把数据库当成一个会说话的对手。它有自己的规则、自己的边界、自己的脾气,你在代码层面做的每一个假设,都得拿到它面前验证一遍。上面这套本机、远程、Docker 三条路线,本质上是在应对不同场景下的确定性问题:本机图快,远程图真实,Docker 图可复现。三者不冲突,我的项目里通常是本机开发时用本机库、提交流水线用容器库、偶尔需要复现线上问题连远程实例,用环境变量切换,配置一套代码通吃。
最后再分享一个小习惯:每次发布前,我会把验收测试对着线上库的只读副本跑一遍。不是全跑,只跑那些涉及查询语义和约束的用例。这一步花不了几分钟,但它抓出过两次“模型定义和线上表结构已经不一致”的问题,那种问题如果等到用户反馈再发现,代价就大得多。