系列专栏:测试工程师每日一博 · Day 1日期:2026-07-07关键词:测试金字塔、JUnit5、JUnit Jupiter、参数化测试、AAA 模式阅读对象:写过测试、但还没系统梳理过测试框架与分层策略的 Java 后端 / 测试工程师开篇:你也在画冰激凌甜筒吗?随便打开一个 Java 团队的 CI 日志,你大概率会看到一种很特别的测试结构:几百条手工用例躺在 Excel / 禅道里(顶层甜筒的尖)几十个 Selenium / 接口跑批测试,跑一次 40 分钟,flaky 率 5%(中间层)底层的单元测试……几乎没有(底部空心)这就是软件测试界臭名昭著的反模式:冰激凌甜筒测试 (Ice Cream Cone),也叫倒金字塔。它的代价不是抽象的——每天都在你团队里发生:反馈慢:每一次 commit 都得等几十分钟才能知道有没有改坏flaky 多:UI / 接口测试不稳定,失败时不知道是代码 bug 还是测试自身的问题修不动:一个底层函数的小改动,让 20 个上层用例集体飘红,根因却淹没在堆栈深处新成员不敢动:谁都不敢删那个我也不知道为什么需要的 Sleep为什么会走到这一步?原因几乎全是惯性:写一个 Selenium 用例看起来覆盖了完整流程,成就感强;写单元测试需要先理解被测代码、还要拆依赖,听起来更累。结果就是堆顶层、漏底层——甜筒就形成了。今天这篇,我们围绕两件事系统讲清楚:测试该按什么形状组织?——测试金字塔用 Java 的话,底层单元测试怎么写才专业?——JUnit5 实战一、测试金字塔:三层 70/20/10测试金字塔最早由 Mike Cohn 在《Succeeding with Agile》里提出,后来被 Martin Fowler 和 Ham Vocke 在系列文章里反复精炼。核心只有一张图:△ ← E2E / UI 测试 (~10%) △△ ← 集成测试 / 接口测试 (~20%) △△△ ← 单元测试 (~70%)三层从下到上,数量递减、速度递减、成本递增、反馈精度递增。层级测什么跑多快数量占比失败定位单元测试单个类 / 函数的纯净逻辑毫秒级70%✅ 极准集成测试多模块协作、真实 DB / MQ秒级20%⚠️ 一般E2E 测试完整业务路径、用户视角分钟级10%❌ 难1.1 为什么底层要多一个朴素的经济学问题:假设一个功能可以用 1 个 E2E 测试覆盖,也可以用 5 个单元测试覆盖,选哪个?E2E:跑 30 秒、不稳定、失败后要截图 看日志 复现;单元测试:跑 5 毫秒、稳定、失败直接指向某行代码。工程师每天可能改十几次代码,每次都触发测试。一个慢一秒的测试,在一年里会浪费整个团队几十个小时。所以金字塔强调:把校验尽可能下放到底层,顶层只保留必须从用户视角验证的关键路径。1.2 三种典型反模式除了甜筒,还有两种常见的形状歪掉:倒金字塔:单元测试几乎为零,全部靠集成 / 接口测试撑场面。最常见于我们没时间写单测的团队。沙漏型 (Hourglass):有大量单元测试,也有大量 E2E,但中间的集成测试断档——底层验完了逻辑、顶层验完了流程,却没人验证这两块拼起来能不能跑。甜筒 / 牛角包 (Ice Cream Cone):顶层是大量手工测试。给自己团队做个体检:打开 CI 最近一周的执行记录,统计三层各自的用例数和总耗时。如果 E2E 用例数 单元测试数,你们就是甜筒——只是还没意识到。二、JUnit5 简介:它不是一个东西很多新人把 JUnit5 当成 “JUnit4 1”,这其实是个误区。JUnit5 是一个由三个子项目组成的平台:JUnit 5 ├── Platform ← 测试运行的操作系统:定义引擎 SPI,集成 IDE / Maven / Gradle / CI ├── Jupiter ← JUnit5 自己的编程模型(Test、ParameterizedTest 等新注解) └── Vintage ← 向后兼容引擎,让 JUnit3/4 的用例也能在 Platform 上跑这意味着——你可以在同一个工程里同时跑 JUnit4 和 JUnit5 的测试,Vintage 引擎负责兜底。迁移成本被压到很低。2.1 JUnit5 vs JUnit4 关键差异速查维度JUnit4JUnit5包名org.junitorg.junit.jupiter.api测试注解Test(类Assert)Test(类Assertions)前置钩子Before/BeforeClassBeforeEach/BeforeAll断言Assert.assertEquals(...)Assertions.assertEquals(...),原生支持 Lambda(超时断言、分组断言)参数化测试需junit-params,功能弱内置ParameterizedTest 多种 source,一等公民嵌套测试不支持Nested(非静态内部类)扩展机制RunWith互斥,只能写一个ExtendWith可叠加条件执行简陋EnabledOnOs、EnabledIfSystemProperty等丰富内置今天若是新项目,闭着眼睛选 JUnit5。若老项目,加 Vintage 渐进迁移,先让新代码用 Jupiter。2.2 一张生命周期图TestInstance(Lifecycle.PER_CLASS)// 默认 PER_METHODclass示例{BeforeAllstaticvoid全类初始化前(){}// 默认要求 staticBeforeEachvoid每个用例前(){}Testvoid用例(){}AfterEachvoid每个用例后(){}AfterAllstaticvoid全类收尾后(){}}执行顺序记住一个口诀:All → each → test → each → All。一个常见坑是:把数据初始化写进BeforeAll,但用例里又修改了它——后续用例读到的就不是干净数据了。BeforeAll适合做只读资源加载(如读配置),写状态请用BeforeEach。三、实战:用 JUnit5 测一个订单价格计算器理论看完了,落点在哪里?我们用一个所有电商团队都见过的业务场景——订单价格计算——演示 JUnit5 的核心用法。它有什么好:业务规则清晰、容易具体到代码,又能覆盖单元测试里几乎所有的常见场景。场景:给定购物车里若干商品 用户优惠券,计算订单实付金额。规则:1. **满减**:订单小计 ≥ 100 元时,减 20; 2. **会员折扣**:会员用户在此基础上再打 9 折; 3. 防御性校验:商品列表为空 / 优惠券已过期等抛异常; 4. 最终金额向上取整到分,且 ≥ 0。3.1 被测代码(产品代码)// OrderPricer.java —— 业务代码,通常放 src/main/javapublicclassOrderPricer{publicstaticfinalBigDecimalFREE_SHIPPING_THRESHOLDnewBigDecimal(100);publicstaticfinalBigDecimalDISCOUNT_WHEN_REACHEDnewBigDecimal(20);publicstaticfinalBigDecimalVIP_DISCOUNT_RATEnewBigDecimal(0.9);/** * 计算订单实付金额 * * param items 购物车商品列表,不可为空 * param coupon 优惠券,可为 null(表示无券);必须未过期 * param isVip 是否会员 * return 实付金额,向上取整到分,且 0 */publicBigDecimalprice(ListOrderItemitems,Couponcoupon,booleanisVip){if(itemsnull||items.isEmpty()){thrownewIllegalArgumentException(购物车不能为空);}if(coupon!nullcoupon.isExpired()){thrownewIllegalStateException(优惠券已过期);}// 小计BigDecimalsubtotalitems.stream().map(i-i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQuantity()))).reduce(BigDecimal.ZERO,BigDecimal::add);// 满减if(subtotal.compareTo(FREE_SHIPPING_THRESHOLD)0){subtotalsubtotal.subtract(DISCOUNT_WHEN_REACHED);}// 会员折扣if(isVip){subtotalsubtotal.multiply(VIP_DISCOUNT_RATE);}returnsubtotal.setScale(2,RoundingMode.CEILING).max(BigDecimal.ZERO);}}3.2 完整测试类下面这个测试类故意写得教科书化,集中演示 JUnit5 的核心特性:importorg.junit.jupiter.api.*;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.*;importjava.math.BigDecimal;importstaticorg.junit.jupiter.api.Assertions.*;classOrderPricerTest{privateOrderPricerpricer;BeforeEachvoidsetUp(){// 每个用例前重建,保证状态隔离pricernewOrderPricer();}// —— 1. 最基础的正向用例 ——TestDisplayName(小计恰好达到 100 元,应触发满减 -20)voidshould_applyDiscount_when_subtotalReachesThreshold(){// ArrangeListOrderItemitemsList.of(item(书,newBigDecimal(60),1),item(笔,newBigDecimal(40),1));// ActBigDecimalpaidpricer.price(items,null,false);// AssertassertEquals(newBigDecimal(80.00),paid);}// —— 2. ValueSource 适合输入→预期的单值参数化 ——ParameterizedTest(name[{index}] 小计 {0} → 实付 {1})CsvSource({60, 60.00,// 不足满减,原价99, 99.00,// 卡在门槛下方 1 分钱100, 80.00,// 恰好达标,减 20200, 180.00// 远超门槛})voidshould_matchDiscountThreshold(BigDecimalsubtotal,StringexpectedPaid){ListOrderItemitemsList.of(item(x,subtotal,1));BigDecimalpaidpricer.price(items,null,false);assertEquals(newBigDecimal(expectedPaid),paid);}// —— 3. 会员折扣与满减叠加 ——TestDisplayName(VIP 用户 200 元订单:满减后 180,再 9 折 162)voidshould_stackDiscountAndVip(){ListOrderItemitemsList.of(item(x,newBigDecimal(200),1));BigDecimalpaidpricer.price(items,null,true);assertEquals(newBigDecimal(162.00),paid);}// —— 4. Nested 分组:把边界和异常分层,提升可读性 ——NestedDisplayName(边界条件)classBoundary{TestDisplayName(实付金额不会出现负数)voidshould_neverReturnNegative(){ListOrderItemitemsList.of(item(x,newBigDecimal(15),1)// 远低于满减);BigDecimalpaidpricer.price(items,null,false);assertTrue(paid.compareTo(BigDecimal.ZERO)0);}TestDisplayName(金额向上取整到分(不会出现 0.001 这种碎屑))voidshould_roundUpToCents(){// 构造 0.01 * 3 0.03,VIP 0.9 折后 0.027 → 向上取整 0.03ListOrderItemitemsList.of(item(x,newBigDecimal(0.01),3));BigDecimalpaidpricer.price(items,null,true);assertEquals(newBigDecimal(0.03),paid);assertEquals(2,paid.scale());// 小数位恰好是 2}}NestedDisplayName(异常路径)classExceptional{TestDisplayName(购物车为空时抛 IllegalArgumentException)voidshould_throw_when_cartEmpty(){IllegalArgumentExceptionexassertThrows(IllegalArgumentException.class,()-pricer.price(List.of(),null,false));assertTrue(ex.getMessage().contains(购物车不能为空));}TestDisplayName(null 购物车抛 IllegalArgumentException)voidshould_throw_when_cartNull(){assertThrows(IllegalArgumentException.class,()-pricer.price(null,null,false));}TestDisplayName(过期优惠券抛 IllegalStateException)voidshould_throw_when_couponExpired(){ListOrderItemitemsList.of(item(x,newBigDecimal(10),1));CouponexpirednewCoupon(LocalDate.now().minusDays(1));assertThrows(IllegalStateException.class,()-pricer.price(items,expired,false));}}// —— 5. MethodSource:适合复杂对象 ——staticStreamArgumentsmixedScenarios(){returnStream.of(// items 单价, 数量, 是否 VIP, 期望实付Arguments.of(newBigDecimal(100),1,false,80.00),Arguments.of(newBigDecimal(100),1,true,72.00),Arguments.of(newBigDecimal(50),1,false,50.00),Arguments.of(newBigDecimal(50),2,true,81.00)// 100 满减到 80,VIP 9 折 72...等等,这个值得专门讨论);}ParameterizedTestMethodSource(mixedScenarios)voidshould_computeAcrossMixedScenarios(BigDecimalunit,intqty,booleanvip,Stringexpected){ListOrderItemitemsList.of(item(x,unit,qty));BigDecimalpaidpricer.price(items,null,vip);assertEquals(newBigDecimal(expected),paid);}// —— 测试辅助方法,降低用例噪音 ——privatestaticOrderItemitem(Stringname,BigDecimalunit,intqty){returnnewOrderItem(name,unit,qty);}}3.3 这段代码示范了什么JUnit5 特性出现位置解决的问题DisplayName几乎每个用例报告里显示中文描述,而不是should_xxx()方法名ParameterizedTestCsvSource第 2 节表格式参数,一行一种场景,降本利器MethodSource第 5 节多字段、复杂对象的参数化,Logic 和 Data 解耦Nested第 4 节把用例分组到边界 / “异常”,报告里像目录树assertThrows第 4 节异常断言,顺带能取到 message 做进一步校验assertTrue(cond, msg)边界用例失败信息带上下文,排障不用 println测试辅助方法item(...)全类降低用例噪音,让Arrange段不至于淹没在 new 里跑一遍下来,IDE 报告里是一棵带中文描述的树:OrderPricerTest ├─ 小计恰好达到 100 元,应触发满减 -20 ✅ ├─ [1] 小计 60 → 实付 60.00 ✅ ├─ [3] 小计 100 → 实付 80.00 ✅ ├─ VIP 用户 200 元订单:满减后 180,再 9 折 162 ✅ ├─ 边界条件 │ ├─ 实付金额不会出现负数 ✅ │ └─ 金额向上取整到分 ✅ └─ 异常路径 ├─ 购物车为空时抛 IllegalArgumentException ✅ ├─ null 购物车抛 IllegalArgumentException ✅ └─ 过期优惠券抛 IllegalStateException ✅这棵树本身就是一份给非测试同事看的测试报告。⚠️注意mixedScenarios的最后一行:我在那里留了一个注释 “这个值得专门讨论”。这不是失误——单元测试里偶尔可以这样做:先打桩、把疑问写进注释,等业务确认后再回填期望值。如果你的团队 CI 跑参数化测试时只看汇总绿,容易忽略注释里的 TODO。养成习惯:TODO 必带负责人 工单号。3.4 这个例子还藏着两个业务测试心法心法一:用临界值做参数化,而不是看起来漂亮的值。注意should_matchDiscountThreshold选取的99 / 100——刚好踩在门槛上下。bug 几乎都长在边界,平整的值(50、100、200)测了也测不出问题。心法二:把防御性校验写成测试,而不是写在注释里。购物车不能为空如果只写注释,半年后新同事改了校验逻辑,注释不会变红。写成should_throw_when_cartEmpty,改坏立刻报错。四、JUnit5 原生断言 vs AssertJ写到这里你会觉得,assertEquals(new BigDecimal(80.00), paid)已经够用。但当断言变复杂时,JUnit 原生 API 会变得啰嗦:// JUnit5 原生:7 行,断言一个购物车的多个属性assertEquals(3,items.size());assertEquals(书,items.get(0).getName());assertEquals(newBigDecimal(60),items.get(0).getUnitPrice());assertNotNull(items.get(0).getCategory());// AssertJ:链式、流式,一句话搞定assertThat(items).hasSize(3).element(0).satisfies(i-{assertThat(i.getName()).isEqualTo(书);assertThat(i.getUnitPrice()).isEqualByComparingTo(60);assertThat(i.getCategory()).isNotNull();});维度JUnit5 原生AssertJ学习成本几乎为零需熟悉链式 API失败信息默认较朴素可读性极强,自动拼出期望 X 实际 Y复杂对象断言要拆成多条一条链式搞定推荐场景简单值断言集合、对象、流式断言,尤其是 E2E / 集成测试实战建议:不是二选一,搭配使用。简单场景用 JUnit5 原生,集合 / 复杂对象用 AssertJ。spring-boot-starter-test默认就把两者一起带进来了。五、测试可读性三原则:AAA、命名、One-Behavior回头看上面OrderPricerTest,你会发现每个用例都遵循同一种节奏:// Arrange —— 准备数据// Act —— 调用被测方法// Assert —— 断言这就是AAA 模式 (Arrange-Act-Assert)。它是测试界的主谓宾,几乎所有优秀测试都能套进去。原则 1:AAA 分段,每段只做一件事把三段用空行 / 注释隔开,读者扫一眼就知道这段在造数据、这段在调真家伙、这段在校验。切忌在 Act 里夹杂数据构造,那是事故温床。原则 2:should_{预期}_when_{场景}命名// ❌ 反例Testvoidtest1(){}TestvoidpriceTest(){}// ✅ 正例Testvoidshould_applyDiscount_when_subtotalReachesThreshold(){}命名格式要求:用should..._when...,而不是test...中英文皆可,但要全工程统一描述行为 条件,不描述实现这种命名的好处是,失败报告一眼能看出违反了哪条业务规则。原则 3:One Test One Behavior不一定严格到 “一个测试一个 assert”,但要做到一个测试只验证一个行为。如果发现一个用例有三个assertEquals,问自己:这三条是不是失败时都能独立定位?如果不能,就该拆。六、容易翻车的 5 个坑在我评审过的代码里,下面五个坑反复出现:测试间状态污染静态字段做大缓存,本机跑得过,jacoco 也漂亮;一上 CI 顺序一变红一片。对策:字段加final、BeforeEach重建、能不动静态就别动静态。浮点数直接equals/ BigDecimal 直接equalsassertEquals(0.1 0.2, 0.3)永远会失败——浮点算出来是0.30000000000000004。BigDecimal 更隐蔽:new BigDecimal(1.0)与new BigDecimal(1.00)equals也是 false,因为 scale 不同。对策:double用带 delta 的重载assertEquals(expected, actual, 0.0001);BigDecimal 用isEqualByComparingTo或断言字符串80.00。Disabled当垃圾桶“先禁了回头再修”——然后回头永远是 6 个月后。对策:禁用必带Disabled(原因 issue 链接),并接 CI 报警:每周扫一遍残留数量。断言错了对象,等于没测常见反例:调用结果赋给一个变量,然后断言另一个无关变量,或者整段测试assertThat(true).isTrue()充数。对策:用变异测试(Mutation Testing,如 PIT)定期体检,它会故意改坏产品代码看测试报不报。测试依赖测试执行顺序用TestMethodOrder强行定顺序,看似优雅、实则高度脆弱。对策:除极少数集成测试场景外,坚持用例独立性。七、把今天的知识接回金字塔回看开篇那张金字塔图。我们今天写的OrderPricerTest全部在最底部——单元测试层,跑得飞快、定位精准。这是金字塔的基石。但真实系统里OrderPricer不可能孤立工作:它要读商品库、要查优惠券有效期的接口、要把订单写库。下一层(集成测试)就要回答这些协作者拼起来能不能跑通。再上一层(E2E)就要回答用户从 App 里下单,看到的是不是 162 元。明天我们会顺着这个台阶往上走一层,聊一个让单元测试从憋屈变舒服的关键武器——Mockito 模拟依赖。届时你会发现:没有 Mockito 的单元测试,要么慢、要么假;有了它,你可以把HttpClient、Database、CouponService全部假装掉,只测纯逻辑。八、思考题(留给明天的引子)写完今天的代码,留两道开放题,你可以在评论区 / 团队群里讨论:Q1:我们的OrderPricer假设优惠券校验是方法参数。如果改成OrderPricer内部调一个CouponService.verify(coupon),你怎么在不连真数据库的情况下测它?A. 测试里也连测试数据库B. 把CouponService抽接口,测试里写一个假的实现C. 用 Mockitomock(CouponService.class)(提示:答案跟明天 Mockito 的Mock/InjectMocks息息相关)Q2:OrderPricer里三个常量(100 / 20 / 0.9)是写死的。如果运营要求双 11 期间门槛调整为 80 / 30 / 0.85,你会怎么改产品代码、又怎么改测试,才能让它们不互相打架?九、总结(TL;DR)测试金字塔:70% 单元 / 20% 集成 / 10% E2E。甜筒、倒金字塔、沙漏都是反模式。JUnit5 Platform Jupiter Vintage。“新项目用 5、老项目靠 Vintage 渐进迁移”。核心写法:ParameterizedTest是降本利器、Nested是组织利器、assertThrows是异常利器。可读性三原则:AAA 分段、should_when_命名、一个用例一个行为。⚠️五个坑:状态污染、浮点 / BigDecimal equals、Disabled垃圾桶、断言错对象、依赖执行顺序。明天我们出:《Mockito 进阶:Mock、Spy、InjectMocks 与过度模拟的代价》。如果这篇对你有帮助,欢迎点赞收藏 ⭐。下篇见。