社群团购系统开发分享——工程化(下):资金支付测试策略——资金模块 100% 覆盖,其他 60% 就够
0. 测试的正确问法不是"测多少",是"哪里多测、哪里少测"
上一篇把规范做成了 CI 卡点,但卡点只能拦"写错的代码",拦不住"逻辑算错的代码"。而这套系统里,逻辑算错的代价高度不均匀:佣金算错 1 分钱,帮卖团长的信任就开始动摇;详情页 banner 渲染慢半秒,几乎没人发现。
所以本篇的核心结论先给出:测试资源不按代码量分配,按资金浓度分配。 资金链路(佣金计算、结算汇总、分账金额)要求 100% 分支覆盖、每个边界用例可追溯到业务规则;其余模块 60% 行覆盖保底,投入立刻衰减。追求全仓 90% 覆盖率的团队,一半的测试是在浪费工时——那些无断言、为凑覆盖率的测试还会给出虚假的安全感。
围绕这个分配逻辑,本篇讲四件事:分层测试金字塔在本项目的落地、佣金计算器的 TDD 全过程(含 12 个边界用例全集)、Testcontainers 替代 H2 的理由与实现、关键路径的自动化回归。

1. 分层测试金字塔:每一层测什么、不测什么
经典的 7:2:1 金字塔我们都背过,难的是落到项目上"什么进哪一层"。我们的划分:
| 层 | 占比 | 测什么 | 明确不测什么 |
|---|---|---|---|
| 单元测试 | ~70% | 佣金计算器、状态机迁移表、防环算法、金额工具、加价校验——一切纯业务规则 | 不测 getter/setter、不测 MyBatis 的 CRUD |
| 集成测试 | ~20% | 库存 Lua 扣减、下单事务(快照落库)、幂等唯一索引、微信回调处理(mock 微信侧) | 不测第三方真实 API |
| E2E / 冒烟 | ~10% | 下单 → 支付回调 → 佣金生成 → 结算汇总的完整链路,每晚跑 | 不做小程序 UI 自动化 |
两个主动放弃的决定,说明理由比决定本身重要:
放弃小程序 UI 自动化。 微信小程序的自动化工具链(miniprogram-automator)维护成本高,且每次发版过审本身就是人工验证的强制点。C 端 UI 变更频率高、资损风险低,自动化的收益覆盖不了成本。E2E 只保留接口层冒烟,每晚对测试环境全链路跑一遍。
放弃全仓 90% 覆盖率目标。 JaCoCo 门禁按模块配置,而不是全仓一个数:
<!-- gbs-domain/pom.xml:资金规则所在的模块,卡到分支级 -->
<plugin>
<artifactId>jacoco-maven-plugin</artifactId>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.95</minimum></limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>
<!-- 其他模块:LINE 0.60,够了 -->
为什么 domain 是 100% 分支而不是 100% 行? 因为行覆盖 100% 但分支覆盖 90% 意味着:每个 if 都执行过,但有一半的 else 分支从没走过——而资金事故恰恰埋在没走过的分支里(差价为负、比例超限、金额为零)。分支覆盖是资金模块唯一的合格线。
2. 佣金计算器 TDD 全过程:红 → 绿 → 重构
佣金计算是全系统被调用最频繁的资金逻辑(第 8 篇的公式在这里兑现),我们用它演示 TDD 的完整节奏。先回顾业务规则:
模式A(未加价):帮卖佣金 =(售卖价 − 供货价)× 佣金比例
模式B(已加价):帮卖佣金 = 售卖价 − 供货价(差价全归团长,不再乘比例)
2.1 第一步:先写失败的测试(红)
TDD 的关键是测试先于实现——还没写计算器,先把"它应该满足什么"写清楚。12 个边界用例,每一个对应一条业务规则或一个历史事故:
class CommissionCalculatorTest {
private final CommissionCalculator calculator = new CommissionCalculator();
// ---------- 模式A:按差价乘比例 ----------
@Test @DisplayName("A-1 常规:差价 1000 分 × 15%(基数字 1500)= 150 分")
void case01_normal() {
long c = calculator.calc(deal(3000), supply(2000), rate(1500), false);
assertEquals(150, c);
}
@Test @DisplayName("A-2 整分取舍:差价 999 × 15% = 149.85 → 舍去小数 = 149(宁少不多)")
void case02_roundDown() {
long c = calculator.calc(deal(2999), supply(2000), rate(1500), false);
assertEquals(149, c);
}
@Test @DisplayName("A-3 差价为负(售卖价低于供货价):佣金为 0,且不抛异常")
void case03_negativeGap() {
long c = calculator.calc(deal(1900), supply(2000), rate(1500), false);
assertEquals(0, c);
}
@Test @DisplayName("A-4 差价为 0:佣金为 0")
void case04_zeroGap() {
long c = calculator.calc(deal(2000), supply(2000), rate(1500), false);
assertEquals(0, c);
}
@Test @DisplayName("A-5 比例下界 0%:佣金为 0")
void case05_zeroRate() {
long c = calculator.calc(deal(3000), supply(2000), rate(0), false);
assertEquals(0, c);
}
@Test @DisplayName("A-6 比例上界 30%(基数字 3000):3000 分 × 30% = 900 分")
void case06_maxRate() {
long c = calculator.calc(deal(5000), supply(2000), rate(3000), false);
assertEquals(900, c);
}
// ---------- 模式B:加价全归团长 ----------
@Test @DisplayName("B-1 加价模式:差价全归团长,忽略佣金比例")
void case07_markupIgnoresRate() {
long c = calculator.calc(deal(3500), supply(2000), rate(1500), true);
assertEquals(1500, c);
}
@Test @DisplayName("B-2 加价但差价为负:佣金为 0(防御,正常下单不应出现)")
void case08_markupNegative() {
long c = calculator.calc(deal(1800), supply(2000), rate(1500), true);
assertEquals(0, c);
}
// ---------- 输入合法性:非法输入要么防御要么快速失败 ----------
@Test @DisplayName("I-1 售卖价为 0(0 元团):佣金为 0,不抛异常")
void case09_zeroPrice() {
long c = calculator.calc(deal(0), supply(0), rate(1500), false);
assertEquals(0, c);
}
@Test @DisplayName("I-2 比例超上界(基数字 3001):抛 BizException,快速失败")
void case10_rateOverflow() {
assertThrows(BizException.class,
() -> calculator.calc(deal(5000), supply(2000), rate(3001), false));
}
@Test @DisplayName("I-3 负数金额:抛 BizException(上游不该产生,但必须防御)")
void case11_negativeAmount() {
assertThrows(BizException.class,
() -> calculator.calc(deal(-100), supply(2000), rate(1500), false));
}
@Test @DisplayName("I-4 幂等序列:同一订单同一规则版本生成恒定的 bizSerial")
void case12_bizSerialIdempotent() {
OrderItem item = itemWithOrderNo("GB20260901ORD0001");
String s1 = calculator.bizSerial(item, ruleVersion(3));
String s2 = calculator.bizSerial(item, ruleVersion(3));
assertEquals(s1, s2);
assertNotEquals(s1, calculator.bizSerial(item, ruleVersion(4)));
}
}
这 12 个用例里有三条设计原则,比用例本身更值得带走:
- 取舍规则显式化:
149.85 → 149舍去小数、宁少不多。钱往哪个方向取整必须是一条被测试钉死的规则,而不是某次代码的偶然行为; - 差价为负不抛异常而是返回 0——它是一次真实事故的产物:某天供货价上调导致历史快照出现负差价,第一版实现直接抛异常,把整个佣金生成队列打挂了。资金代码对"业务上不该出现的输入"的正确姿态是记账 + 兜底为 0 + 告警,而不是中断主流程;
- 比例上界快速失败:与差价为负相反,比例超界属于"上游逻辑坏了",静默兜底会掩盖 bug,必须炸出来。
工程上还有一步落地动作:模式 A 的六个纯计算用例(A-1 到 A-6)最终重构成了 JUnit 5 的参数化测试——
@ParameterizedTest(name = "[{index}] {0}")
@CsvSource({
"常规场景, 3000, 2000, 1500, 150",
"整分取舍舍去, 2999, 2000, 1500, 149",
"差价为零, 2000, 2000, 1500, 0",
"差价为负兜底, 1900, 2000, 1500, 0",
"比例下界零, 3000, 2000, 0, 0",
"比例上界三十, 5000, 2000, 3000, 900",
})
void calcPatternA(String desc, long deal, long supply, int rate, long expected) {
assertEquals(expected, calculator.calc(deal, supply, rate, false));
}
六行 CSV 就是六条规则的"对账单",新增边界值时加一行数据而不是复制一个测试方法——规则表和测试表合二为一,这也是资金类纯计算逻辑最舒服的测试形态。I 类用例(非法输入、幂等)因为断言行为不同(抛异常 vs 返回值),保持独立方法。
2.2 第二步:最小实现(绿)
/** 佣金计算器 —— 唯一实现位于 gbs-domain(第 9 篇纪律二:资金规则只在 domain 层) */
public class CommissionCalculator {
/** 模式A 基准最大比例:基数字 3000 = 30%,超出即上游异常 */
private static final int RATE_MAX = 3000;
public long calc(long dealPrice, long supplyPrice, int rateBase, boolean priceMarkup) {
checkInput(dealPrice, supplyPrice, rateBase);
long gap = dealPrice - supplyPrice;
if (gap <= 0) {
return 0; // 差价为负/为零:兜底为 0,不中断主流程
}
if (priceMarkup) {
return gap; // 模式B:加价差价全归团长
}
// 模式A:整分乘法后向下取整(金额一律分,比例用基数字,无浮点)
return MoneyUtil.mulFloor(gap, rateBase);
}
private void checkInput(long dealPrice, long supplyPrice, int rateBase) {
if (dealPrice < 0 || supplyPrice < 0) {
throw new BizException(ErrorCode.COMMISSION_NEGATIVE_AMOUNT);
}
if (rateBase > RATE_MAX) {
throw new BizException(ErrorCode.COMMISSION_RATE_OVERFLOW);
}
}
public String bizSerial(OrderItem item, int ruleVersion) {
// 幂等序列:order_no + 规则版本,撞 t_commission_record.uk_biz_serial 唯一索引
return item.getOrderNo() + ":" + ruleVersion;
}
}
2.3 第三步:重构但保持绿
实现通过后做了一次重构:把"差价 <= 0 返回 0"的逻辑提到最前面,因为三种模式(含未来的推荐奖)在差价非正时都应该短路返回 0——这个共性是写完测试才看清的。重构阶段测试的价值就是让你敢动代码:12 个用例全绿,怎么改都有人兜底。
3. 集成测试:为什么放弃 H2,用 Testcontainers
单测覆盖了计算逻辑,但库存扣减、下单事务这类依赖数据库真实行为的测试,H2 会说谎:
| 测试场景 | H2 的表现 | 真实 MySQL 的表现 |
|---|---|---|
UPDATE ... SET stock = stock - 1 WHERE stock >= 1 条件扣减 |
语法能跑,但锁行为与 InnoDB 不同 | 行锁竞争、affectedRows=0 判定准确 |
uk_biz_serial 唯一索引幂等 |
支持,但死锁/重复键异常的传播路径不同 | 与线上一致 |
| Lua 脚本扣减 Redis | 完全无法模拟 | 单线程原子语义真实 |
集成测试的关键是"测试环境和线上的行为契约一致",H2 恰恰在最重要的地方(锁、异常传播)不一致。Testcontainers 用 Docker 拉起真实的 MySQL 8 和 Redis 7,代价只是每次多跑几秒:
@Testcontainers
@SpringBootTest
class StockDeductIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7.2").withExposedPorts(6379);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", mysql::getJdbcUrl);
r.add("spring.datasource.username", mysql::getUsername);
r.add("spring.datasource.password", mysql::getPassword);
r.add("spring.data.redis.host", redis::getHost);
r.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired StockDeductService stockService;
@Test
@DisplayName("并发 100 线程抢 50 件库存:Redis 恰好扣 50,DB affectedRows=0 的请求全部回滚")
void concurrentDeduct_noOversell() throws Exception {
stockService.load(1L, 50); // SKU=1 装载库存 50
int threads = 100;
var pool = Executors.newFixedThreadPool(threads);
var latch = new CountDownLatch(threads);
var success = new AtomicInteger();
for (int i = 0; i < threads; i++) {
final long uid = i;
pool.execute(() -> {
try {
if (stockService.deduct(1L, uid, 1) == 1) {
success.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await();
assertEquals(50, success.get()); // 超卖 = 失败
assertEquals(0, stockService.redisStock(1L)); // Redis 恰好清零
}
}
这个"100 线程抢 50 件"的用例是我们集成测试里含金量最高的一条——它复现了截单前一小时的真实并发形态,而任何 mock 环境都测不出超卖。Testcontainers 的容器在类生命周期内复用,整套集成测试 40 多个用例,跑完约 90 秒,在 CI 里完全可接受。
4. 关键路径自动化回归:一条每晚都跑的资金链路
单元与集成测试覆盖"正确性",还有一类风险是"链路性"的:某个改动单看没问题,但把"下单 → 回调 → 佣金 → 结算"整条链路串起来时,状态对不上。我们的应对是一个每晚跑的接口级冒烟脚本,验证四个断言:
- 下单成功,且订单明细里价格快照、帮卖绑定 ID 落库正确;
- 模拟微信支付回调(重复推送 3 次),订单只变一次已支付,佣金记录只生成一条(
uk_biz_serial生效); - 手动退款,佣金记录状态回滚为"已回滚",库存回补数量精确等于购买数量;
- 结算汇总,对一批订单跑结算任务两次,第二次结算金额不变(任务幂等)。
这条链路每晚对测试环境跑一遍,失败即邮件告警。告警的响应约定也提前定好:资金链路冒烟失败按 P1 级处理——值班同学第二天上班第一件事复跑确认,确认非环境抖动(测试环境依赖超时是常见的假阳性,复跑一次区分)就当场定位修复,禁止"先记着回头查"。回归测试的公信力就是靠"红了必有人管"养出来的,告警被忽略三次的冒烟脚本,等于没有。
它抓到的都是单测抓不到的问题——最典型的一次是某次"优化"把佣金计算改成了异步线程池,支付回调测试全绿,但冒烟脚本发现回调后 500ms 内佣金还没落库,导致结算任务扫不到。链路断言盯的是时序与一致性,那是任何单测的盲区。
最后澄清一个容易走偏的点:覆盖率是卫生指标,不是目标。我们禁止两种造假——写无断言的测试凑覆盖率、为覆盖率去测试私有方法。判断一个测试值不值得存在的标准只有一条:改坏了它能不能红。 不能红的测试是负资产,它消耗 CI 时间,还给你虚假的安全感。
5. 测试基建的三个补充:命名、数据、环境
策略之外还有三件"不起眼但决定测试能否长期维护"的事。
第一,用例名 = 业务规则。 前文所有用例都用了 @DisplayName 写成人话,这不是审美偏好,是维护成本问题。半年后某个佣金用例红了,case02_roundDown 加上"整分取舍:差价 999 × 15% = 149.85 → 舍去小数 = 149(宁少不多)"这行描述,任何人都知道是取舍规则变了还是代码错了;只有 testCalc2 的用例,红了你也不敢删也不敢改,最后变成永久跳过的僵尸测试。我们的约定:每个资金类测试方法的命名格式是"编号 + 规则描述",规则描述必须能让不懂代码的产品经理看懂。
第二,测试数据用建造者,不用魔法数。 12 个佣金用例里反复出现 deal(3000)、supply(2000)、rate(1500),这是一组工厂方法而不是裸数字:
/** 测试数据工厂:默认值合法,用例只显式声明与被测规则相关的参数 */
private static OrderItem deal(long cents) {
return OrderItemFixture.builder().priceDeal(cents).build();
}
价值在维护:金额字段从"元改分"之类的模型变更发生时,只改一处工厂;更重要的是用例只展示"与规则相关的差异"——case02 读者一眼看出它唯一的变量是 999 这个差价,其他参数都是背景噪音。
第三,集成测试的数据隔离靠事务回滚 + 独立 schema 双轨。 单库共享的集成测试,用例间数据污染是最常见的翻车点。我们的双轨约定:普通用例走 @Transactional 自动回滚,跑完库是干净的;但涉及"事务提交后才触发"逻辑的用例(如事务消息、回调后的异步佣金计算)不能回滚——回滚后异步线程读不到数据,测试结果失真。这类用例改走"独立 schema + 用例级清理钩子":每个用例用随机前缀建数据(订单号带用例 ID),清理钩子按前缀删除。慢一点,但结果可信。
顺带交代灰度与测试的关系:测试再全也只能证明"测试环境是对的",涉及分账逻辑的变更上线时依然按第 9 篇的灰度流程走——新版本先放 5% 流量,灰度期间人工复核每一笔分账指令。资金相关的发布,谨慎冗余不算过度设计,这是测试与发布之间最后的一道缝,用人工缝上。
6. 本篇小结
- 测试资源按资金浓度分配——资金模块 100% 分支覆盖,其余 60% 行覆盖;全仓统一高覆盖率目标是浪费;
- 分支覆盖 > 行覆盖——资金事故埋在没走过的 else 分支里;
- 佣金计算器 12 个边界用例先于实现——取舍方向、负差价兜底、比例上界快速失败,每条用例对应一条被钉死的规则;
- H2 在锁与异常传播上说谎——集成测试用 Testcontainers 拉真 MySQL + Redis,"100 抢 50"用例复现截单脉冲;
- 每晚一条资金链路冒烟——单测盯正确性,链路盯时序与幂等;
- 覆盖率是卫生指标——无断言测试是负资产,唯一标准是"改坏了它能不能红"。
地基至此全部打完。从下一篇开始进入系列的核心引擎区:《开团引擎:团购状态机的 7 个状态与并发安全的开始》——一场团购从创建到截单的完整生命周期,以及定时截单和手动截单"打架"时怎么让系统永远只认一个答案。
(本系列所有文章为独立技术实践记录,代码均经过本地运行验证。转载请注明出处。)
浙公网安备 33010602011771号