资讯详情 Caveman框架:用极简断言让Java测试代码如大白话
📅 2026/10/29 13:52:24
写 Java 测试的人应该都经历过这种时刻测试代码写到最后比业务代码还难读。一个简单的方法调用为了验证返回值得套上assertTrue、assertEquals再配上临时变量想测异常得写try-catch-fail的祖传三段式想验证一个集合里有没有某个元素先把集合转成字符串再contains一把——总觉得哪里不对但又说不出到底差在哪。我第一次接触 Caveman 这个极简风格的 Java 测试工具时那种感觉很像从手动挡换到自动挡原来测试可以只写“我想验证什么”不用写“我怎么去验证”。它不会替代 JUnit也不会像 AssertJ 那样给你几百个断言方法而是把绝大多数常见场景的断言代码压缩到最短读起来像一句大白话。这篇文章我会从问题源头讲起带你从零跑通一个 Caveman 风格的测试用例再聊聊我在真实项目里用它重构测试代码的完整经过以及那些只有踩过坑才会知道的边界和限制。不管你是刚入门的 Java 新人还是正被一坨测试代码折磨的老手这篇内容应该都有参考价值。1. 测试代码越写越重问题到底出在哪在聊 Caveman 之前我想先把测试代码为什么容易变重这件事拆开讲清楚因为很多工具选型的误区本质上都是对“测试代码的复杂度来源”理解不够。1.1 JUnit 原生断言的“凑合”气质JUnit 本身只提供最基础的断言能力而且这些 API 设计得相当朴素。写assertEquals(3, result)没问题但一旦涉及集合、异常、异步味道就变了。// 验证一个 List 包含指定元素 ListString names service.getNames(); assertTrue(names.contains(zhangsan)); // 验证一个 Map 的某个 key 有值且值正确 MapString, Integer scoreMap service.getScores(); assertTrue(scoreMap.containsKey(math)); assertEquals(98, scoreMap.get(math)); // 验证方法抛出了指定异常 try { service.doSomething(); fail(应该抛异常但没有抛); } catch (BizException e) { assertEquals(ERR_001, e.getCode()); }这三段代码都能跑也都能通过测试但读起来不舒服。问题在于assertTrue(x.contains(y))本质上是在“用描述实现细节的方式表达业务意图”。你真正想说的是“列表里有 zhangsan”代码写的却是“断言这个 list 调用 contains 方法返回 true”。一旦断言失败JUnit 只会告诉你 expected true but was false——连是哪个元素缺失都不告诉你。异常测试那个try-catch-fail更是老 Java 程序员闭着眼都能背出来的模板但它有个隐蔽的坑如果doSomething()在抛BizException之前先抛了个NullPointerException测试会直接失败你根本不知道是业务代码错了还是测试代码错了。异常断言缺乏“精确匹配异常类型”的能力是 JUnit 原生断言的硬伤。1.2 团队测试风格的“军阀混战”项目稍微大一点团队协作的复杂度就会叠加到这个问题上。有人觉得 JUnit 原生的够用有人引进了 Hamcrest 的that()匹配器还有人直接上 AssertJ 的流式断言。最后的结果就是同一个项目里三个测试文件的写法三种风格。更麻烦的是AssertJ 这类功能完善的断言库本身带的知识量不小。你得记住assertThat(list).containsExactly(...)和assertThat(list).containsOnly(...)的差异得区分isEqualTo和isSameAs还得理解extracting、filteredOn这些进阶 API。工具能力强是好事但每一项强能力都在给使用者加认知负担。我见过不止一个团队测试代码里写着一大串 AssertJ 链式调用看起来非常高级但实际有一半方法是靠 IDE 自动补全凑出来的写完没人能准确说出这个断言的匹配语义。这就像给每个家庭都配了一台赛车性能是够了但大多数人只是在小区里买个菜。1.3 Caveman 的取舍逻辑只解决高频的 80%Caveman 的思路跟这些“全功能断言库”不太一样。它的核心哲学可以概括成一句话测试代码应该写成“行为描述”而不是“验证步骤”。它刻意砍掉了大量低频使用的高级断言只保留覆盖日常多数场景的若干写法并且把这些写法的 API 统一到一个非常简单的入口上。有人可能觉得这太简陋了。但换个角度想软件行业这么多年积累下来的经验恰恰是一个工具覆盖面越广使用者的心智负担越重团队的行为一致性越差。Caveman 有点像是“带着镣铐跳舞”——它主动放弃一部分能力换取的是新成员看一眼就能写出风格统一的测试Code Review 不再需要为“断言语义对不对”争论高频场景的代码量直接减半失败信息更贴近业务语言排查问题更快。这就是我选择在特定项目里引入它的根本原因。工具不是越强越好匹配场景才好。2. 从环境准备到第一个用例Caveman 的极简入口前面讲了一堆理念现在进入实操。这部分我会带你走完整个环境准备和写第一个测试用例的过程顺带把 Caveman 的核心设计逻辑拆开看。2.1 引入依赖轻到几乎没有感知我使用 Caveman 时的做法是把它的依赖加到测试源码集里不碰主代码。Maven 项目就是pom.xml的dependencyGradle 项目就是build.gradle里的testImplementation。dependency groupIdcaveman4j/groupId artifactIdcaveman-testing/artifactId version参考你引入时的最新版本号/version scopetest/scope /dependency我不是很清楚“caveman4j”在不同时期的确切坐标写法——这是开源工具很常见的情况版本迭代后 groupId 或 artifactId 会调整。你引入时最可靠的方式就是打开项目的 GitHub 仓库 README直接复制当前的依赖声明。我文章里所有代码是基于我自己实际使用过的某个版本还原的核心思路没有变化但 API 命名可能因为版本更新有差异。理解这一点比死记硬背某个方法名更重要。2.2 第一个用例从一个fire开始Caveman 的入口思想很直白把你想要测试的行为“点着”然后看它烧出什么结果。我当时最早写的用例是下面这样import caveman.tester.CavemanTester; import org.junit.jupiter.api.Test; public class CalculatorTest { private final Calculator calculator new Calculator(); Test void testAdd() { CavemanTester.test(calculator) .fire(c - c.add(1, 2)) .returns(3); } }对就是这十几行。对比前面 JUnit 原生的写法核心差异有两处第一被测对象和方法调用被统一封装了。test(calculator)指定被测对象fire(c - c.add(...))用一个 lambda 把“要执行的行为”传进去。你用不着先算出一个结果变量再单独写断言——fire 方法直接把行为执行和结果捕获合并成一步。第二断言动作变成了链式调用的动作描述。.returns(3)这个读法非常接近英文的自然语义执行add(1, 2)这个行为之后返回 3。看测试代码的人不需要脑内翻译“assert”是什么意思扫一眼就知道在验证什么。我第一次跑通这个用例的时候最直观的感受是测试方法的命名可以不卷了。以前为了把测试意图写清楚方法名经常是shouldAddTwoNumbersAndReturnTheCorrectResult这种一串驼峰。现在方法名只需要一个动词短语testAdd真正的意图都写在fire那行里了。2.3 多个断言不用造一堆临时变量实际测试里很少只断言一次。用 Caveman 风格处理多断言我觉得最大的价值不是行数减少而是“中间变量的命名焦虑”消失了。Test void testOrderInfo() { OrderService service new OrderService(); CavemanTester.test(service) .fire(s - s.createOrder(用户A, 3)) .returns(order - { assertNotNull(order.getOrderNo()); assertTrue(order.getTotalPrice() 0); assertEquals(待支付, order.getStatus()); }); }看到.returns(order - {...})这个写法了吗Caveman 允许你在收到返回值之后做更精细的校验。这里的闭包参数order就是 fire 行为的返回对象你不用在测试方法外部声明一个成员变量保存中间结果直接在断言块里使用。这种“行为—结果—断言”三段式结构把每一个测试用例都变成了一则微型故事。读代码的人先看到被测对象再看到动作最后看到期望结果——顺序和思路完全一致。而原生 JUnit 的写法往往是声明变量、调用方法、写断言三段代码散布在方法体各角落中途还可能插入一些日志打印或其他逻辑。2.4 失败信息长什么样测试工具到了一定程度大家追求的不只是“能测出来”更是“失败时一眼能定位问题”。Caveman 的失败信息走的是“把实际值和期望值摆在一起对质”的路子。举例来说如果上面那个用例算出来是 4运行后控制台不会只给你一句“expected: 3 but was: 4”而是会输出类似下面这种结构不同版本格式有差异思路一致Caveman assertion failed Behavior: Calculator.add(1, 2) Expected: 3 Actual: 4关键在于它把Behavior这行打出来了。调试的时候你能直接看到是哪个行为、哪个参数组合出了偏差而不用从堆栈信息里反推测试代码的位置。这一点在参数化测试里尤其好用后面我会再提到。3. 集合、异常和动态场景的实测记录跑通基础用例之后就该把 Caveman 放到更真实的场景里去检验了。这一节的内容全来自我实际写测试时的记录包括集合断言、异常断言、异步和动态数据的处理以及它们各自的适用边界。3.1 集合断言顺序、包含、排他集合是 Java 业务代码里出场率最高的数据结构也是断言最容易写绕的地方。Caveman 在集合断言上采用的策略是把“集合层面的语义”显式化。我常写的几种场景// 验证集合内容精确匹配顺序有关 service.getOrderIds() .should() .containExactly(1001, 1002, 1003); // 验证集合包含某个元素 service.getNames() .should() .contain(zhangsan); // 验证集合不包含某个元素 service.getBannedUsers() .should() .notContain(lisi);注意containExactly和contain的区别。containExactly要求元素全部匹配且顺序一致适合验证“这批数据就是按这个顺序返回的”比如时间倒序的列表contain只要求包含指定元素适合验证“某个用户在这个名单里”。语义清清楚楚摆在 API 名字上不搞isIn、hasSize、extracting那一大套。集合断言还有一个高频场景需要提醒像List和Set的断言语义完全不同Caveman 的匹配规则会顺从集合本身的equals实现。意味着如果你在自己的实体类里没有重写equalscontainExactly可能退化成引用比较导致测试莫名其妙失败。这是我后面会细说的一个坑。3.2 异常断言终于不用 try-catch 了我前面提到过try-catch-fail三段式的痛点。Caveman 对异常场景的封装是我最喜欢它的原因之一写法直接到令人感动Test void testBizException() { CavemanTester.test(new PaymentService()) .fire(s - s.pay(-100)) .throwsException(BizException.class) .withMessageContaining(金额不能为负数); }这里有两个能力值得展开throwsException精确指定异常类型withMessageContaining校验异常信息内容。异常类型匹配能过滤掉那些“碰巧抛出来的无关异常”信息校验则能防止“啥都不管直接抛个 RuntimeException 糊弄人”的垃圾测试。而且你注意到没有整套写法根本不存在“fail()”这个键盘上的脏词。因为是否抛异常已经内建在断言链条里了如果方法没抛异常这条链式调用本身就会执行失败并给出明确提示。异常测试终于不再需要那套祖传 try-catch这是一个非常舒爽的体验提升。3.3 连续行为与链式调用模拟真实流程很多业务测试不是测一个孤立的动作而是要测一连串的流程。订单从创建到支付再到发货中间每一步都可能改状态。用原生 JUnit 写这种场景你得把每一步的动作和断言交错着写一段代码里逻辑跳来跳去。Caveman 对这一类场景的支持方式是通过多次 fire 保持同一个被测对象的状态再把每一步的断言紧跟其后Test void testOrderLifecycle() { CavemanTester.test(orderService) .fire(s - s.createOrder(1)) .returns(order - order.getStatus().equals(待支付)) .fire(s - s.pay(1)) .returns(order - order.getStatus().equals(已支付)) .fire(s - s.ship(1)) .returns(order - order.getStatus().equals(已发货)); }这段代码的可读性在于一眼望去整个订单生命周期的状态流转在六行内呈现得清清楚楚。而原生写法大概是Order o1 service.createOrder(1); assertEquals(待支付, o1.getStatus()); Order o2 service.pay(1); assertEquals(已支付, o2.getStatus()); Order o3 service.ship(1); assertEquals(已发货, o3.getStatus());看起来原生版本也没差多少但注意原生写法里pay(1)返回的不一定带着完整Order状态——很可能服务设计是pay(int orderId)返回void那你为了验证状态必须重新调getOrderById(1)多一行而 Caveman 的fire每次都基于同一个底层被测对象去操作如果你在test(orderService)里把“操作执行”和“取值方法”组合好可以设计出更贴合业务语义的链条。换个角度说Caveman 鼓励你把被测对象的内部状态变化作为主线而不是把临时变量传来传去。3.4 参数化与动态数据它的短板所在讲到这里我得诚实地亮一下 Caveman 的短板。我前面说过它刻意砍掉低频高级断言这个“砍”在某些场景里是让人肉疼的。首先是复杂嵌套结构的断言。如果返回的是一个多层嵌套的ListMapString, ListSomeDtoCaveman 的 API 表达能力会很快触顶。它适合断言“结果大概是这个意思”但不适合执行“这个 JSON 结构的第三层数组里的第二个对象的某个字段必须等于特定值”这种细粒度校验。遇到这种场景我通常的做法是让 Caveman 只断言最外层结构是否符合预期再配合 Jackson 把返回结果序列化为一个 DTO对 DTO 内部字段单独用原生断言或 AssertJ 校验。其次是异步与超时断言。Caveman 的理念本质上是同步验证——“执行一个行为立刻检查结果”。如果被测方法是异步回调或者需要轮询拿结果Caveman 本身不提供任何超时等待的机制。我实际项目里就踩过这个坑服务内部用了线程池异步处理测试里 fire 完立刻 return返回结果永远为空。后面我的处理方案是用Awaitility这类专门做异步等待的工具把轮询逻辑包一层等异步结果就绪后再交给 Caveman 做断言。两个工具各管一段不冲突。最后是随机性与动态生成数据的场景。比如你的服务返回结果里面有一个端到端生成的 UUID或者一个当前时间戳这类字段本质上是“不可预测”的。用原生 JUnit 你也很难断言但至少你能写一个正则表达式去匹配格式。Caveman 的策略是建议你把这类动态字段在设计测试时就排除掉或者在断言块里不校验具体值只校验结构存在性。换句话说它不提供“忽略指定字段”这种级联操作——这种能力不是它能覆盖的。4. 一次真实重构把混沌的测试代码改成 Caveman 风格理论讲再多不如直接看一个真实的重构案例。我带过的一个老项目测试代码积累了三四年风格混杂得让人头疼。有段时间改一个支付金额校验逻辑光是找相关的测试用例就花了一下午——因为不同人写的测试命名和断言方式相差太大用关键词搜索都搜不干净。后来我下决心用一个迭代周期的空闲时间把支付模块的测试代码整块重写顺便引入了 Caveman 风格。整个过程大概是这样的。4.1 重构前一坨“能跑但没人愿意读”的测试这是当时真实存在的测试代码我做了脱敏和精简结构和味道保留原样public class PaymentServiceTest { private PaymentService service; BeforeEach void setUp() { service new PaymentService(); } Test public void payTest() throws Exception { // 计算金额 MapString, Object params new HashMap(); params.put(userId, 1001); params.put(amount, 50.0); params.put(couponId, null); boolean result service.pay(params); Assert.assertTrue(支付应该成功, result); // 再验证订单状态 Order order service.getLastOrder(1001); Assert.assertNotNull(order); Assert.assertEquals(支付中, order.getStatus()); // 验证支付记录 ListPaymentRecord records service.getRecords(1001); Assert.assertTrue(records.size() 0); Assert.assertEquals(50.0, records.get(0).getAmount(), 0.001); } Test public void testFail() throws Exception { MapString, Object params new HashMap(); params.put(userId, 1001); params.put(amount, -5.0); params.put(couponId, null); try { service.pay(params); Assert.fail(不应该支付成功); } catch (BizException e) { Assert.assertEquals(金额不合法, e.getMessage()); } } }这段代码的问题我不想一一陈列了列出来能写三页。最刺眼的有payTest一个方法里塞了三组验证失败时根本分不清是金额算错了还是订单状态不对参数组装用了MapString, Object传参意图全被掩盖变量result、order、records散落各处读代码要来回跳testFail这名字是什么鬼它测的明明是“金额不合法时应该拒绝支付”但方法名完全没表达出来。4.2 重构后测试代码变成了业务行为清单重写的时候我首先跟开发确认了几个关键行为“支付成功时订单状态是什么”“金额为负数时抛什么异常信息是什么”“支付后返回什么结构”全部确认清楚之后新测试写成下面这样class PaymentServiceTest { private final PaymentService service new PaymentService(); Test void payWithValidAmountCreatesPendingOrder() { PaymentRequest request PaymentRequest.of(1001, 50.0); CavemanTester.test(service) .fire(s - s.pay(request)) .returns(result - { assertTrue(result.isAccepted()); Order order service.getLastOrder(1001); assertEquals(支付中, order.getStatus()); ListPaymentRecord records service.getRecords(1001); assertEquals(1, records.size(), 支付记录应只有一条); assertEquals(50.0, records.get(0).getAmount(), 0.001); }); } Test void payWithNegativeAmountThrowBizException() { PaymentRequest request PaymentRequest.of(1001, -5.0); CavemanTester.test(service) .fire(s - s.pay(request)) .throwsException(BizException.class) .withMessage(金额不合法); } }两相对比我认为最大的进步不只是代码行数少了而是“意图密度”提高了。测试名告诉你这是“支付金额有效时应该生成待支付订单”方法体内的 chain 顺序就是业务行为的自然顺序发起支付、检查返回、检查订单状态、检查记录数量、检查金额精度。就算一个完全不了解这个模块的新人来看也能在三秒内 get 到这段测试在守护什么业务规则。4.3 重构中最容易被忽略的两个动作这次重构里有两件事如果没做效果会大打折扣。第一把参数从 Map 改成领域对象。PaymentRequest.of(1001, 50.0)这个静态工厂方法是我在测试源码里加的。因为原代码的MapString, Object让 pay 方法的入参语义极其模糊测试写完还得靠业务代码注释才能理解。重构测试时如果完全不动被测接口只换断言写法那你只是把“混沌”换了一种形式。一定程度上测试代码的可读性依赖于被测接口本身的表达能力这层债必须先还掉。第二顺带梳理了测试数据工厂。重构前每个测试方法都在手工 newHashMap、拼参数重复代码一大堆。那一次我顺手在测试工程里加了一个PaymentRequests工厂类集中提供各种合法/非法请求的构造方法。这样既方便新测试快速拿到目标数据也避免了每个测试类各自维护一份参数样板。4.4 重构之后我发现的一件事大约过了两周有一次代码评审里一个同事指着一个 Caveman 测试链问我“这里.fire(...)之后为什么没有.returns(...)”我看了一眼发现是因为被测方法返回void只有副作用没有返回值所以这条链到fire就结束了。这让我意识到一个件事Caveman 的风格天然鼓励“让被测方法要么返回有意义的对象要么抛出明确的异常”。如果你的服务方法既不返回结果也不抛异常fire 之后真的没事可做断言链会非常空洞。这种“工具倒逼设计”的效果不在官方文档里是在实际用之后才浮现的。后来我们团队对服务层设计多了一条不成文的约定需要被测试的业务方法尽可能返回一个代表行为结果的对象哪怕只是ActionResult这种轻量 VO而不是返回void靠内部状态被人偷窥。因为 Caveman 的 chain 写起来太好用了你不想让链子断掉。5. 实践中的坑以及我最后的几条使用建议最后这部分我把这段时间踩过的坑和沉淀下来的判断标准整理一下。就算你不打算引入 Caveman这些内容放在任何测试工具选型里都适用。5.1 坑一别让它断言重写了 equals 的对象这是最容易踩、也最隐蔽的坑。Caveman 的集合断言依赖于集合元素自己的equals方法。如果你的 DTO 没有重写equalscontain就会按照引用比较来判断结果明明元素内容一样测试却失败了。当时我排查这个问题花了将近四十分钟因为失败信息里显示的元素内容看起来完全一致。解法也很直接要么给 DTO 补上equals/hashCode通常业务上本来就该有要么在断言块里对每个字段单独校验绕过集合级断言。我的建议是优先选择前者——让领域对象具备值语义不只是为了测试团队其他地方的集合处理也会受益。5.2 坑二别期待它覆盖“具有专门语义”的断言场景前面提到过复杂嵌套、异步、动态数据这些短板。我再补一个例子JSON 结构校验。如果你测试的是一个 HTTP 接口对外的 JSON 返回response.getBody()返回了一长串 JSON 字符串用 Caveman 做contains会非常别扭——你应该去匹配字符串但它不提供 JSONPath 一类的定位能力。我的经验是明确划分工具的职责边界。Caveman 负责“业务行为层面”的验证也就是被测对象的输入输出是否满足业务规则如果你的测试涉及协议层面JSON 结构、XML 节点、数据库查询结果请引入专业的断言工具或直接使用结构化等价类比较。工具不是仇敌是互补没有哪个工具能通吃所有断言硬拿 Caveman 去表达 JSONPath 语义只会让测试代码更怪。5.3 坑三团队落地时统一风格比工具能力更重要如果你想把 Caveman 引入团队或者说引入任何测试工具我都强烈建议先解决一个前置问题团队里现在测试代码风格混乱到什么程度如果现状是 60% 的人用 JUnit 原生、40% 的人用 AssertJ那你引不引入 Caveman 其实不是最关键的最关键的是让大家对一套风格达成共识。否则过半年就会出现“新老划断”的两套体系并存维护成本不减反增。具体到 Caveman我给团队的落地建议是先挑一个业务相对独立、测试代码收益明显的模块比如支付、结算做试点试点的目标是产出一份团队内部的测试写法规范明确哪些场景用 Caveman 的 chain哪些场景必须退回到原生断言Code Review 环节专门留一个检查点测试是否表达清楚了业务意图而不是只盯着“覆盖率数字”。别贪多。开始只用 Caveman 覆盖集合、异常、流程这三种高频场景其他的先不管。5.4 一个小技巧把测试代码当作另一种产品文档来写写到这里我想分享一个个人体会一个测试工具好不好用最终看的是它能不能把测试代码变成团队里最容易被理解的那份文档。Caveman 的 chain 式写法给你的不是“断言能力”而是一种表达习惯——先写行为、再写结果、最后写校验。顺着这个习惯写下去你会在不知不觉中把测试组织成一份“可执行的业务行为清单”。在维护那个老项目的后期我们团队接手新需求时第一件事就是翻相关模块的测试代码。不是看文档文档经常过期也不是看提交记录commit message 经常含糊。就是看 Caveman 的那些 chain ——它们列出了这个模块应该遵守的所有业务规则而且每条规则都是活的一跑就知道当前版本是否还遵守。我觉得这才是测试代码最理想的存在状态。工具叫 caveman土是土了点但“回到最原始、最直白地描述行为”这件事恰恰是现代软件开发里最稀缺的。