利益相关:笔者是多租户微信小程序 SaaS 项目的主力工程师,技术栈 Java 21 + Spring Boot + PostgreSQL,27 个业务域。本文数字均为项目真实口径:250 个测试类、1241 个测试方法、85 个迁移文件(版本至 V113)。
一、背景:测试多不等于测试好
先说结论:测试数量本身不说明任何问题。1241 个测试如果是无差别堆出来的,跑得慢、挂得莫名其妙、没人敢修,反而是负资产。我们真正花心思的不是"多测",而是"分层"——每一层只测它该测的东西,测试挂掉时能立刻判断问题出在哪一层、该找谁修。
自下而上,测试越跑越慢、越接近真实、数量越少。
二、四层结构:从纯逻辑到拟真旅程
1. 领域单测:纯逻辑,最快
27 个业务域的实体、值对象、状态机,全部脱离 Spring 上下文运行:不起容器、不连数据库,一个测试就是一个普通 JUnit 方法。
典型写法是状态迁移矩阵穷举。以订单状态机的 handle(PayEvent) 为例:输入不是"一个支付事件",而是"前置状态 × 支付事件"的组合。每个状态乘每个事件列成矩阵,合法格断言成功,非法格逐格断言拒绝,拒绝后实体分毫未动:
// 领域单测:状态迁移矩阵的一格(脱敏伪代码)
@Test
void paidOrderRejectsDuplicatePayEvent() {
Order order = OrderFixture.paid(); // 已支付订单
Instant paidAt = order.paidAt(); // 记录现场
Result r = order.handle(PAY_EVENT); // 再来一次支付事件
assertThat(r.rejected()).isTrue(); // 必须拒绝
assertThat(order.status()).isEqualTo(PAID); // 状态不变
assertThat(order.paidAt()).isEqualTo(paidAt); // 不留痕迹
}
2. 持久层与守卫测试:跑在真 PostgreSQL 上
这层守两件事:其一,迁移能从零库完整建出目标结构——85 个文件按序执行到 V113,不留缝隙;其二,RLS 行级安全与约束真的拦得住越权写入。
守卫测试就是"摆好会话,做一次越权动作,断言结果"。以迁移验证为例:V89 给 orders 表加租户隔离,先以 A 租户写入一行订单,再切 B 租户会话去读、去写:
-- 守卫测试:B 租户会话读不到 A 租户的行(脱敏)
SET app.tenant_id = 'tenant_b';
SELECT count(*) FROM orders WHERE tenant_id = 'tenant_a';
-- 断言:count = 0
INSERT INTO orders (tenant_id) VALUES ('tenant_a');
-- 断言:写入被拒,报错含表名
最后一条不只测"拦得住",还测"报错可读"——错误信息是排查越权时唯一的文档。
3. 契约测试:对第三方网关的 HTTP 契约
支付、短信这类外部依赖不真调,也不简单 mock 一个返回值——那等于自说自话。我们为每个网关固化 HTTP 契约(URL、方法、状态码、报文骨架),测试用契约回放;网关改版时先改契约文件,测试立刻变红,逼着代码同步。一个请求响应对长这样:
# 契约片段:下单接口(脱敏)
request:
method: POST
path: /v1/pay/orders
body: { out_trade_no: "T-20260901-0001", amount_in_cents: 9900 }
response:
status: 200
body:
prepay_id: "<随机串>"
断言两件事:请求符合契约(字段名、必填、金额单位是分);解析吃得下骨架,含 prepay_id 超长、金额为零的边界。网关改字段名,先红的是契约测试,不是线上回调。
4. 端到端拟真旅程:自建模拟器上的黄金旅程
最上层只保留少量"黄金旅程":从进店、下单、支付回调到核销的主路径,跑在自建模拟器上,外部网关全部换成契约桩。它最慢,所以只留主路径,细节断言一律下沉到下面三层——端到端测试的职责是证明"各层接得起来",而不是重复验证每一层内部的正确性。
黄金旅程是一份序列清单,每步只断言交接物正确:
- 顾客扫码进店,建立会话,断言拿到正确的店铺上下文;
- 提交订单,断言订单落库、状态为待支付;
- 模拟网关回调支付成功,断言订单转已支付、核销码生成;
- 店员角色扫码核销,断言核销码失效、订单到达终态。
三、双数据库策略:H2 快跑,PostgreSQL 兜底
日常开发与大部分自动化测试用 H2 内存库,启动快、销毁干净;但 H2 没有 RLS,方言也不完全兼容。于是维护双通道:
- 通用迁移放主目录,H2 与 PostgreSQL 共用;
- PostgreSQL 专属迁移(RLS 策略、部分索引)放单独目录,只在 PG 通道执行;
- CI 上设 PostgreSQL 矩阵作业,保证专属能力在真库上行为一致。
一句话分工:H2 管速度,PostgreSQL 管真实。凡 H2 验证不了的能力,必须有对应的真库测试兜底。
这条分工来自一次"H2 全绿、PG 挂掉"。现象:合并后 PG 通道迁移测试红了一片,报历史数据违反唯一索引,而 H2 从没红过。定位:出事迁移里有个带 WHERE 的部分唯一索引——同租户单号唯一,只约束未删除的行。H2 不支持部分索引,兼容模式下直接跳过这条语句:不报错,也不建索引。所以本地不是"测过了",是"根本没执行";PG 真把索引建起来后,历史数据里恰好有同租户重复单号,迁移当场挂掉。修法三步:部分索引挪进 PG 专属目录,H2 不再假装执行;补数据去重的修正迁移;加守卫测试,断言索引存在、同单号二次写入被拒。自此定规:凡 H2 认不全的迁移语法,一律按 PG 专属处理,不赌兼容模式。
四、运行策略:分层执行,CI 按影响面选择
本地开发默认只跑领域单测和持久层测试,改哪层跑哪层;合并主干前跑全量。CI 不做无脑全跑,而是按影响面选择:只动了某个业务域的纯逻辑,就跑该域单测加依赖它的下游;动了迁移文件或 RLS 相关代码,无条件触发 PostgreSQL 全矩阵。原则是:影响面小跑得少,影响面大谁也跑不掉。
五、什么值得测,什么不值得
值得把测试密度堆上去的三类:
- 状态迁移矩阵穷举:每个状态 × 每个事件列成矩阵,合法迁移断言成功,非法迁移逐格断言拒绝;
- 幂等重放:支付回调重放三遍,断言只落一笔账;
- 双租户互攻:A 租户拿 B 租户的资源 ID 去读、去改、去核销,逐个断言 404 或 403。
共同点:一旦失守就是资损或越权,且是"静默错"——不报错、不崩,账面多一笔,数据漏一行。测试密度该跟着事故代价走,不跟代码行数走。
不值得测的,值得专门说一段。getter/setter、框架自身能力(如 @Transactional 是否生效)这类东西,测它不产生信息量:失败只能告诉你"代码和测试写得不一致",告诉不了你"业务要出事故"。1241 个方法里没有一个在测 getter。隐性成本更麻烦:每次重构都要陪着改,陪改还会引入新错;全量被拖长,长到有人跳过全量,"全绿"这个词就失效了。"不值得测"和"值得测"平级:评审出现纯 getter 测试直接打回;框架能力交给框架自己的测试体系,我们只测自己写的胶水。
六、踩坑清单
每条按现象、定位、修法:
- H2 与 PostgreSQL 方言差异。现象:本地全绿,PG 通道作业红。定位:对比两通道执行的迁移集合,锁定被 H2 兼容模式吞掉的语句。修法:PG 特有语法挪专属目录,合并检查清单加一项"是否含 PG 专属写法"。
- 迁移只跑增量。现象:新迁移在开发库正常,新环境从零建库失败。定位:从零库重放全部 85 个文件,历史迁移被后手改过,与后续冲突。修法:迁移测试从零库跑全量;已合入文件禁止修改,修正用新文件表达。
- 契约文件没有 owner。现象:网关改版后回调解析挂了,契约测试却绿——契约没同步。定位:契约改动无责任人,改版公告没人认领。修法:契约头部标注负责业务域,公告路由到对应域;变红按 owner 分派,不在群里问"谁的"。
- 端到端旅程悄悄膨胀。现象:一条主路径长出七条变体,频繁因无关改动挂掉。定位:变体在重复验证下层细节。修法:设硬上限(条数与断言数),超了下沉;新增旅程要说明验证哪条接缝。
- 守卫测试只测"拦得住"。现象:越权被拦了,错误只有一串约束代号。定位:RLS 与约束的默认报错不带业务语境。修法:加"报错可读"断言——能看出哪个租户、哪张表、哪条规则拦的,必要时靠迁移里的命名约定自解释。
三句话总结
- 测试的价值在分层:越往下越快越多,越往上越慢越少,挂了能按层定位;
- 双数据库是分工不是负担:H2 管速度,PostgreSQL 管真实,PG 专属能力必须有真库兜底;
- 把测试密度花在会出事故的地方——状态迁移、幂等重放、租户互攻,永不测 getter。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
