Caveman框架:用极简断言让Java测试代码如大白话

发布时间:2026/10/29 13:52:10
Caveman框架:用极简断言让Java测试代码如大白话

写 Java 测试的人应该都经历过这种时刻:测试代码写到最后,比业务代码还难读。一个简单的方法调用,为了验证返回值,得套上assertTrue、assertEquals,再配上临时变量;想测异常,得写try-catch-fail的祖传三段式;想验证一个集合里有没有某个元素,先把集合转成字符串再contains一把——总觉得哪里不对,但又说不出到底差在哪。

我第一次接触 Caveman 这个极简风格的 Java 测试工具时,那种感觉很像从手动挡换到自动挡:原来测试可以只写“我想验证什么”,不用写“我怎么去验证”。它不会替代 JUnit,也不会像 AssertJ 那样给你几百个断言方法,而是把绝大多数常见场景的断言代码压缩到最短,读起来像一句大白话。这篇文章我会从问题源头讲起,带你从零跑通一个 Caveman 风格的测试用例,再聊聊我在真实项目里用它重构测试代码的完整经过,以及那些只有踩过坑才会知道的边界和限制。不管你是刚入门的 Java 新人,还是正被一坨测试代码折磨的老手,这篇内容应该都有参考价值。

1. 测试代码越写越重,问题到底出在哪

在聊 Caveman 之前,我想先把测试代码为什么容易变重这件事拆开讲清楚,因为很多工具选型的误区,本质上都是对“测试代码的复杂度来源”理解不够。

1.1 JUnit 原生断言的“凑合”气质

JUnit 本身只提供最基础的断言能力,而且这些 API 设计得相当朴素。写assertEquals(3, result)没问题,但一旦涉及集合、异常、异步,味道就变了。

// 验证一个 List 包含指定元素 List<String> names = service.getNames(); assertTrue(names.contains("zhangsan")); // 验证一个 Map 的某个 key 有值且值正确 Map<String, 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的<dependency>,Gradle 项目就是build.gradle里的testImplementation。

<dependency> <groupId>caveman4j</groupId> <artifactId>caveman-testing</artifactId> <version>参考你引入时的最新版本号</version> <scope>test</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实现。意味着如果你在自己的实体类里没有重写equals,containExactly可能退化成引用比较,导致测试莫名其妙失败。这是我后面会细说的一个坑。

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 的短板。我前面说过,它刻意砍掉低频高级断言,这个“砍”在某些场景里是让人肉疼的。

首先是复杂嵌套结构的断言。如果返回的是一个多层嵌套的List<Map<String, List<SomeDto>>>,Caveman 的 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 { // 计算金额 Map<String, 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()); // 验证支付记录 List<PaymentRecord> 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 { Map<String, 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一个方法里塞了三组验证,失败时根本分不清是金额算错了还是订单状态不对;
  • 参数组装用了Map<String, 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()); List<PaymentRecord> 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)这个静态工厂方法是我在测试源码里加的。因为原代码的Map<String, 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 没有重写equals,contain就会按照引用比较来判断,结果明明元素内容一样,测试却失败了。

当时我排查这个问题花了将近四十分钟,因为失败信息里显示的元素内容看起来完全一致。解法也很直接:要么给 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,土是土了点,但“回到最原始、最直白地描述行为”这件事,恰恰是现代软件开发里最稀缺的。