资讯动态

Hamcrest自定义匹配器:原理、实战与测试断言优化指南

发布时间:2026/10/21 8:45:12 来源:尧图企业网站定制
1. 为什么需要自定义匹配器先聊一个很真实的场景。你写单元测试一开始用assertThat(user.getNickname(), is(老张))这样一句一句断言感觉还挺顺手。但随着被测对象越来越复杂比如要校验一个用户详情对象的七八个字段你就会发现原生的hasProperty配合链式allOf的组合越来越臃肿读起来非常费劲而且测试失败时打出来的信息长到根本不想看。Hamcrest 的核心思路其实不是断言而是匹配。assertThat(actual, matcher)的表达方式让你可以用一句近乎自然语言的话来描述期望结果。但内置的匹配器集合是有限的一旦遇到业务对象、时间格式、定制化的集合结构就得自己写匹配器。自定义匹配器并不是一个冷门的高级玩法它是 Hamcrest 真正拉开与其他断言库差距的地方。这篇文章我会从原理讲到实操把为什么这么写踩过哪些坑都聊透适合已经会用assertThat和内置匹配器、但想在复杂断言场景里把测试代码写出可读性和可维护性的开发者。2. 原生匹配器在复杂断言场景下的真实局限2.1 从1个断言到4000个字段的失控过程我先举一个我实际经历过的例子。某管理系统里有个用户详情查询接口返回的对象结构很复杂除了用户基本信息还带了账户状态、会员等级、最近交易等一大堆字段。刚写测试时我用的是这样一串断言assertThat(dto.getNickname(), is(老张)); assertThat(dto.getMobile(), is(138****1234)); assertThat(dto.getStatus(), is(1)); assertThat(dto.getBalance(), is(new BigDecimal(1024.50))); assertThat(dto.getVipLevel(), is(3)); assertThat(dto.getLastTradeTime(), notNullValue());十几条assertThat堆在一起每一条都是独立断言任何一条失败后面的代码一样会继续执行——这其实还算能接受。真正的问题是当测试用例多起来以后每个用例里都是这么一坨粘贴复制一改就是一份新用例字段一增加所有用例都要跟着改。后来我学乖了用allOf把它们合并成一条assertThat(dto, allOf( hasProperty(nickname, is(老张)), hasProperty(mobile, is(138****1234)), hasProperty(status, is(1)), hasProperty(balance, is(new BigDecimal(1024.50))), hasProperty(vipLevel, is(3)), hasProperty(lastTradeTime, notNullValue()) ));这条断言是原子的要么一起过要么一起失败。但问题来了失败的时候控制台打出来的信息是这样的Expected: (hasProperty(nickname, is(老张)) and hasProperty(mobile, is(138****1234)) and hasProperty(status, is(1)) and hasProperty(balance, is(1024.50)) and hasProperty(vipLevel, is(3)) and hasProperty(lastTradeTime, notNullValue())) but: property balance was 1024.51你光看这条信息根本不知道nickname、mobile、status这些字段到底是不是也错了。你只知道balance不对然后得手动把dto打出来对着期望值一个一个查。这在字段少的时候还能忍字段一旦上千调试效率低到让人崩溃。2.2 链式断言的可读性与复用性双重缺失hasProperty这个内置匹配器还有个更隐蔽的问题它是基于反射去读属性的。你写hasProperty(vipLevel, is(3))如果UserDetailDto里根本没有vipLevel这个 getter它不会报编译错误而是等测试运行到这一步才炸。对于重构频繁的项目这种运行期才知道写错了的断言维护成本很高。再有就是复用性。同一套字段校验逻辑你在用户详情测试里用在用户列表测试里可能还要用每次都是复制一份allOf的长链。哪天接口加了一个字段你要跑到七八个测试类里去改漏改一个测试照样绿但线上就出事。其实本质上是这么回事我们把期望什么和如何描述差异这两件事混在了一起。原生匹配器在所有场景下都是通用化的表达适合简单的数据结构而业务对象有固定的结构、固定的约束这种业务断言更适合封装成自己的匹配器让测试代码只负责描述意图不用关心校验的细节。2.3 一个典型失败案例时间格式与脱敏规则另一个让我觉得必须写自定义匹配器的场景是校验接口返回的手机号脱敏格式。比如期望是138****1234但你拿到的是13800001234或者反过来期望完整号码拿到的却是脱敏过的。用正则判断也行但 JUnit 自带的matchesPattern匹配器在 Hamcrest 里并不是所有版本都有封装你得自己拼。即使拼出来了失败信息也只是字符串不匹配完全看不出两边各自长什么样。这类带业务规则的校验最合适的方式就是写一个匹配器把脱敏规则、时间格式规则这些逻辑放进matchesSafely里同时让describeMismatchSafely把实际值原样打印出来。这样测试代码一句话就完了而且失败信息一眼就能看出问题在哪。3. 自定义匹配器的核心原理与心智模型3.1 Matcher接口的三个方法责任边界要分清Hamcrest 的MatcherT接口其实很简单就三个方法public interface MatcherT { boolean matches(Object item); void describeTo(Description description); void describeMismatch(Object item, Description mismatchDescription); }三个方法各管一摊matches(Object item)判断 item 是否满足匹配条件返回 boolean。这是核心逻辑。describeTo(Description description)描述匹配器期望什么也就是期望值的说明。测试失败时这句话会出现在失败信息的前半部分。describeMismatch(Object item, Description mismatchDescription)描述实际值离期望差在哪。这句话会出现在失败信息的后半部分。理解这三个方法的分工很关键。describeTo是静态的、与具体待测值无关的describeMismatch是动态的要基于实际的 item 来描述。很多第一次写自定义匹配器的人会把注意力全放在matches上忽略两个 describe 方法。其实在真正的失败场景里describeTo和describeMismatch才是帮人快速定位问题的关键。测试断言失败如果只显示false那等于没写。3.2 为什么直接实现Matcher接口要谨慎直接用匿名类实现MatcherT也可以跑起来但有一个很实际的坑matches方法接收的是Object类型要实现类型检查和 null 检查。比如你写的是MatcherUserDetailDto但matches里可能拿到一个null或者一个String。你必须在方法里自己判断Override public boolean matches(Object item) { if (item null) { return false; } if (!(item instanceof UserDetailDto)) { return false; } UserDetailDto dto (UserDetailDto) item; // 实际校验逻辑 return ...; }每个匹配器都这么写一遍代码重复率极高而且类型强转容易漏检查。Hamcrest 官方其实早就想好了这个问题提供了TypeSafeMatcherT这个抽象基类。它帮你把类型检查、null 检查都封装好了你只需要实现matchesSafely(T item)。如果你的匹配逻辑是基于某个特征的预期来写的还有更省事的FeatureMatcherT, U可以直接组合已有的匹配器。3.3 自定义匹配器的NPC模型匹配、描述、差异我用一个通俗的类比来帮助理解。想象你是一个面试官matches就是判断候选人合不合格describeTo是招聘启事上写的职位要求describeMismatch是面试后告诉候选人你差在哪。招聘启事describeTo要在候选人到来之前就写好它描述的是这个岗位的通用要求。面试反馈describeMismatch则是针对每个具体候选人给差异说明张三的英语差李四的项目经验不足每个人说的话都不一样。Hamcrest 的匹配器也是同样的设计哲学期望描述是共享的、复用的差异描述是个性化的、针对具体失败值的。抓住这个模型写任何自定义匹配器都能思路清晰。还有一个细节值得注意describeMismatch不是简单地把describeTo和实际值拼在一起。很多内置匹配器的describeMismatch会把期望值的关键维度和实际值的对应部分并列展示。比如is(3)匹配到4会输出was 4简洁明确。你自己写的时候也要尽量往这个方向靠拢。4. 从简单到高级的自定义匹配器实操演变4.1 第一步从匿名类开始跑通最小闭环先不管优雅不优雅用最直接的方式写一个判断用户昵称等于指定值的匹配器MatcherUserDetailDto nicknameMatcher new BaseMatcherUserDetailDto() { Override public boolean matches(Object item) { if (item null || !(item instanceof UserDetailDto)) { return false; } return 老张.equals(((UserDetailDto) item).getNickname()); } Override public void describeTo(Description description) { description.appendText(a UserDetailDto with nickname \老张\); } };这个写法能跑但问题很明显类型检查和强转写了一遍null 处理写了一遍而且describeMismatch没实现失败信息会非常简陋。如果你拿这个匿名类去跑了失败用例会发现输出大概长这样Expected: a UserDetailDto with nickname 老张 but: was UserDetailDto1234abcd实际值打的是一个对象引用地址你根本看不到 nickname 到底等于什么。所以紧接着就要解决两个问题类型安全和差异描述。4.2 第二步用TypeSafeMatcher封装基础设施TypeSafeMatcherT是我写自定义匹配器时用得最多的基类。它做了两件事在matches里先做instanceof检查再做 null 检查类型不对直接返回 false把真正的校验逻辑丢进抽象方法matchesSafely(T item)你只需要关心业务校验。同时它还提供了describeMismatchSafely(T item, Description mismatchDescription)这个钩子默认实现会打印toString但你可以覆盖它来输出更详细的差异信息。举一个实际例子。我要校验一个用户对象的脱敏手机号期望的脱敏规则是前3位****后4位。我可以这样写public class MaskedMobileMatcher extends TypeSafeMatcherUserDetailDto { private final String expectedMobile; public MaskedMobileMatcher(String expectedMobile) { this.expectedMobile expectedMobile; } Override protected boolean matchesSafely(UserDetailDto item) { String actual item.getMobile(); if (actual null) { return false; } return expectedMobile.equals(maskMobile(actual)); } Override public void describeTo(Description description) { description.appendText(a UserDetailDto with masked mobile ).appendValue(expectedMobile); } Override protected void describeMismatchSafely(UserDetailDto item, Description mismatchDescription) { String actual item.getMobile(); mismatchDescription.appendText(mobile was ).appendValue(actual) .appendText( (masked: ).appendValue(maskMobile(actual)).appendText()); } private static String maskMobile(String mobile) { if (mobile null) { return ; } return mobile.replaceAll(^(\\d{3})\\d{4}(\\d{4})$, $1****$2); } }注意到失败信息的设计我把实际手机号和脱敏后的结果都打出来了。假设实际号码是13911112222脱敏后是139****2222而期望是138****1234输出会非常明确Expected: a UserDetailDto with masked mobile 138****1234 but: mobile was 13911112222 (masked: 139****2222)看到这条信息第一时间就能确认是号码本身不对还是脱敏规则不对排查效率提升一大截。这也是我一直想强调的一点describeMismatchSafely不是可有可无的装饰它是你这个匹配器价值的一半。测试经常在深夜加班时跑挂一条能说清楚差异的失败信息能帮你省下半小时的 debug 时间。4.3 第三步用FeatureMatcher复用已有匹配器TypeSafeMatcher已经挺好用了但有一个场景还能更进一步当你需要校验的特征值本身已经可以用现成的匹配器表达时比如is(3)、containsString(abc)、greaterThan(0)再重复写一套matchesSafely就有点冗余了。Hamcrest 提供的FeatureMatcherT, U就是干这个的。它的设计思路是你提供一个如何从 T 对象里提取出 U 特征的方法然后把剩下的匹配工作委托给一个现成的MatcherU。看一个例子。我要校验用户的balance大于 0且vipLevel大于 1。用FeatureMatcher写public static MatcherUserDetailDto hasBalanceGreaterThan(BigDecimal minBalance) { return new FeatureMatcherUserDetailDto, BigDecimal( greaterThan(minBalance), balance greater than, balance) { Override protected BigDecimal featureValueOf(UserDetailDto actual) { return actual.getBalance(); } }; }调用的时候assertThat(dto, hasBalanceGreaterThan(new BigDecimal(100.00)));如果匹配失败Hamcrest 会自动生成类似这样的信息Expected: balance greater than 100.00 but: balance was 50.00你注意到没有整个describeTo、describeMismatch的逻辑FeatureMatcher都帮你生成好了。你只需要描述提取什么特征和特征应该满足什么条件。这就是FeatureMatcher的优雅之处你不再跟Description打交道只跟特征提取打交道剩下的交给组合逻辑。4.4 第四步静态工厂方法与匹配器库的沉淀当你有了一个好的匹配器应该把它包装成静态工厂方法。这样调用方只需要引用一个方法名不需要关心匹配器类是怎么实现的。比如public final class UserDetailMatchers { private UserDetailMatchers() {} public static MatcherUserDetailDto withNickname(String nickname) { return new TypeSafeMatcherUserDetailDto() { // ...省略实现 }; } public static MatcherUserDetailDto withMaskedMobile(String mobile) { return new MaskedMobileMatcher(mobile); } public static MatcherUserDetailDto withPositiveBalance() { return hasBalanceGreaterThan(BigDecimal.ZERO); } }随着项目发展这些静态工厂方法会慢慢沉淀成一个业务断言词汇库。测试代码看起来就像在读需求文档assertThat(dto, allOf( withNickname(老张), withMaskedMobile(138****1234), withPositiveBalance() ));可读性比hasProperty(nickname, is(老张))高多了而且字段名如果重构了只要改匹配器内部实现所有测试用例都不用动。这一步看起来简单但我见过很多开发者卡在这里。他们能写出匹配器类却忘了加工厂方法这一层封装。结果每个测试类里都new一个匹配器代码还是不干净。加一层静态工厂方法其实成本极低收益却非常明显调用点变得简洁、统一匹配器实现细节被完全隔离。5. 实战案例一个用户信息查询对象的完整校验方案5.1 待测对象结构与痛点分析假设我们要测试这样一个接口返回对象我简化了字段保留核心——public class UserDetailDto { private String nickname; private String mobile; // 脱敏后返回 private int status; // 1正常2禁用 private BigDecimal balance; private int vipLevel; private LocalDateTime lastTradeTime; private ListString tags; // 用户标签列表 // getter / setter 省略 }这个对象在接口返回时所有字段都要校验。用原始链式写法会有一个隐蔽的问题status和vipLevel是基本类型int如果接口返回的 JSON 缺了这个字段反序列化默认值会是 0。也就是说你断言status is (1)可能能过但字段其实没传上来。这类问题在原生断言里很难发现除非你同时断言了类型或hasProperty的存在性。5.2 用组合方式编写多字段匹配器我推荐的方案是用FeatureMatcher把每个字段拆成一个独立的匹配器再用allOf组合起来。不要试图写一个大而全的匹配器把所有字段都管了那样跟hasProperty链并没有什么本质区别反而牺牲了复用性。先为每个字段建一个工厂方法public static MatcherUserDetailDto withNickname(String expected) { return new FeatureMatcherUserDetailDto, String(is(expected), nickname, nickname) { Override protected String featureValueOf(UserDetailDto actual) { return actual.getNickname(); } }; } public static MatcherUserDetailDto withMaskedMobile(String expected) { return new FeatureMatcherUserDetailDto, String(is(expected), masked mobile, mobile) { Override protected String featureValueOf(UserDetailDto actual) { return actual.getMobile(); } }; } public static MatcherUserDetailDto withStatus(int expected) { return new FeatureMatcherUserDetailDto, Integer(is(expected), status, status) { Override protected Integer featureValueOf(UserDetailDto actual) { return actual.getStatus(); } }; } public static MatcherUserDetailDto withBalance(BigDecimal expected) { return new FeatureMatcherUserDetailDto, BigDecimal(is(expected), balance, balance) { Override protected BigDecimal featureValueOf(UserDetailDto actual) { return actual.getBalance(); } }; }调用时组合assertThat(dto, allOf( withNickname(老张), withMaskedMobile(138****1234), withStatus(1), withBalance(new BigDecimal(1024.50)), withVipLevel(3), withLastTradeTime(notNullValue()), withTags(hasItem(VIP)) ));这个写法的好处是所有FeatureMatcher失败时都能给出Expected: nickname ... but: nickname was ...这种定位清晰的信息。如果三个字段都错了Hamcrest 的allOf会在but部分列出所有不满足的子匹配器的差异描述。你一眼就能看到是nickname、balance还是tags出了问题不用再手动打印对象排查。5.3 组合所有字段时的describeTo优化使用allOf组合多个匹配器时还有一个信息质量问题。默认情况下allOf的describeTo会把每个子匹配器的描述用and连接起来变成一长串Expected: (nickname is 老张 and masked mobile is 138****1234 and status is 1 and ...)如果匹配器多了这行字会很长读起来很累。更严重的是失败时它会把所有子匹配器的describeTo都打出来不管这个子匹配器是否失败。比如balance是唯一失败的那个但describeTo还是会打出一长串全部期望干扰视线。一个有效改善办法是对一组期望单独定义一个描述型匹配器。做法是用一个自定义的TypeSafeDiagnosingMatcherHamcrest 提供的另一个基类支持在matchesSafely里直接写mismatchDescription把所有字段的校验放在一个方法里手动拼一个可读的期望描述。这个需求很像把校验逻辑汇总成一个断言短语。我自己常用的写法是这样的public static MatcherUserDetailDto matchesAllCoreFields(String nickname, String mobile, int status, BigDecimal balance) { return new TypeSafeDiagnosingMatcherUserDetailDto() { Override protected boolean matchesSafely(UserDetailDto item, Description mismatchDescription) { return validateAll(item, mismatchDescription, new Validation(nickname, item.getNickname(), is(nickname)), new Validation(mobile, item.getMobile(), is(mobile)), new Validation(status, item.getStatus(), is(status)), new Validation(balance, item.getBalance(), is(balance)) ); } Override public void describeTo(Description description) { description.appendText(UserDetailDto with ) .appendText(nickname).appendValue(nickname).appendText(, ) .appendText(mobile).appendValue(mobile).appendText(, ) .appendText(status).appendValue(status).appendText(, ) .appendText(balance).appendValue(balance); } }; }这里的Validation是一个小小的结构体持有字段名、实际值、对应匹配器。遍历它逐个调用matcher.matches(actualValue)一旦不通过就往mismatchDescription里写一条差异记录private static boolean validateAll(Object item, Description mismatchDescription, Validation... validations) { boolean allMatch true; for (Validation v : validations) { if (!v.matcher.matches(v.actualValue)) { if (!allMatch) { mismatchDescription.appendText(, ); } mismatchDescription.appendText(v.fieldName was ).appendValue(v.actualValue); allMatch false; } } return allMatch; }这种写法的优势在于失败信息高度定制化。假设只有balance和mobile失败输出会是Expected: UserDetailDto with nickname老张, mobile138****1234, status1, balance1024.50 but: mobile was 139****2222, balance was 50.00干净、聚焦、一眼定位。相比allOf的长列表这种可读性要舒服得多。代价是你要多写一点代码但对于真正的高频校验场景这个投入是完全值得的。5.4 扩展到集合与列表元素校验用户详情里还有tags这个列表字段。如果你要断言标签列表里包含一个值为 VIP 的元素推荐直接复用内置的hasItem配合自己的字段匹配器assertThat(dto, withTags(hasItem(VIP)));withTags的实现依然是FeatureMatcherpublic static MatcherUserDetailDto withTags(Matcher? super ListString subMatcher) { return new FeatureMatcherUserDetailDto, ListString(subMatcher, tags, tags) { Override protected ListString featureValueOf(UserDetailDto actual) { return actual.getTags(); } }; }注意subMatcher的类型是Matcher? super ListString而不是MatcherListString这是 Java 泛型里常见的逆变设计。hasItem(VIP)返回的是MatcherIterable? super String之类的东西跟我们想要的MatcherListString之间有一个逆变兼容的关系。如果你学我一开始那样直接写MatcherListString编译器会报类型不匹配的错误原因就在这里。还有更复杂的场景断言列表里至少有一个元素满足我们自定义的用户匹配器可以用hasItem(withNickname(老张))。这里hasItem接收的是一个Matcher? super T所以你的withNickname匹配器可以直接嵌套进去。这种嵌套能力正是 Hamcrest 匹配器体系的精髓匹配器可以像积木一样层层叠起来表达出很复杂的业务规则而不需要写 if-else 去遍历判断。6. 常见问题与排查技巧实录6.1 匹配器失败输出看不懂先检查describeMismatch我踩过最多次的坑是自己写的匹配器匹配失败了但控制台输出就是一句Expected: a UserDetailDto with masked mobile 138****1234 but: was UserDetailDto1a2b3c4d问题出在没覆盖describeMismatchSafely。TypeSafeMatcher的默认实现只打印对象的toString()而UserDetailDto没重写toString打印的就是对象引用。解决方式很简单要么给 DTO 重写toString要么在匹配器里覆盖describeMismatchSafely。测试类里的 DTO 大多数没有重写toString所以第二个方案更实用。覆盖时记得把实际拿到的值打出来而不是只打一个对象。6.2 匹配器入参和返回值怎么处理null自定义匹配器里有两种 null 需要分开处理第一种是matches阶段拿到的待测对象本身是 null。用TypeSafeMatcher时它会在进入matchesSafely之前就返回 false不会让你去处理 null 对象。但如果直接用Matcher接口写这一步必须自己做。第二种是待测对象里的某个字段是 null。这个要在matchesSafely里自行判断。比如getMobile()返回 null而你的脱敏规则会对 null 做replaceAll调用就会抛 NullPointerException。我在一个项目里碰到过这个问题接口某个字段在数据库为空时返回 null而断言期望是这个字段等于某个脱敏值。当时没做 null 判断测试不报不匹配而是直接抛异常排查了半天才反应过来。6.3 泛型擦除带来的类型强转陷阱MatcherT的matches(Object item)接收的是 Object这是 Hamcrest 从 1.x 就固定下来的设计。如果你在matches里直接写T casted (T) item;在运行时T的类型已经被擦除这个强转在大多数情况下不会报错但编译期会给出 unchecked 警告。更危险的是如果传入的对象类型根本不匹配你强转之后调用方法可能抛出 ClassCastException。真正稳妥的做法是用TypeSafeMatcherT它内部帮你做了instanceof检查类型不匹配直接返回 false不会跑到你的业务逻辑里去。除非你有特殊的原因必须直接实现Matcher接口否则不要徒手处理类型检查。6.4 匹配器误判为true的隐蔽原因还有一个非常隐蔽的问题某个字段匹配器认为通过了但实际值是 null。比如new FeatureMatcherUserDetailDto, String(containsString(abc), nickname, nickname) { Override protected String featureValueOf(UserDetailDto actual) { return actual.getNickname(); } };如果actual.getNickname()返回 nullcontainsString会怎么处理Hamcrest 的StringContains.matchesSafely会先判断actual null返回 false。但有些第三方的Matcher实现不一定做了这一步。所以我的建议是在featureValueOf阶段就把 null 归一化或者干脆在提取特征时做一个默认值。比如protected String featureValueOf(UserDetailDto actual) { return actual.getNickname() null ? : actual.getNickname(); }这样至少不会因为 null 导致匹配器内部抛 NPE。6.5 同一个匹配器类复用于多种场景的命名法实际项目里匹配器类会越来越多。我见过有人把所有自定义匹配器都堆在一个类里几百行维护起来非常痛苦。我的建议是按被测对象或业务模块拆分。比如用户模块就放UserDetailMatchers交易模块放TradeMatchers。每个类保持纯静态工厂方法 私有构造器的结构这样测试代码引用时一眼就能看到匹配器归属哪个模块。命名上也有讲究。withNickname、withMaskedMobile这种以with开头的命名比hasNickname更贴近对象的属性语境。assertThat读起来就变成了assert that dto with nickname 老张虽然不完全符合英语语法但团队内部用习惯了之后每个人看见withXxx就知道是对象某个属性应该满足什么约束沟通成本很低。6.6 一条翻车的必看提示不要过度设计最后提醒一句。自定义匹配器不是越多越好。如果一个断言只需要assertThat(list, hasSize(3))就能说清楚就没有必要为了统一风格去包一层withUserListSize(3)。匹配器的价值在于业务约束被封装、失败信息被定制而不是为了包而包。判断标准很简单如果这个断言在项目里出现了三次以上或者失败信息每次都让人困惑才值得封装成自定义匹配器。否则用原生匹配器更高效。7. 测试代码里怎么用好自定义匹配器自定义匹配器写完了最终要回到测试代码里去用。我总结一个落地路径第一步先把高频、跨用例复用的校验逻辑封装成匹配器。不用一开始就追求大而全从最让你痛苦的断言开始。比如我最早封装的就是脱敏手机号和余额校验因为这两个字段每次都错得莫名其妙。第二步用静态工厂方法包装给匹配器起一个好读的名字。把assertThat(dto.getBalance(), is(new BigDecimal(1024.50)))改写成assertThat(dto, withBalance(new BigDecimal(1024.50)))差别看似不大但后者在失败信息里会带上 balance 字段名可读性提升很明显。第三步在项目里约定一套命名规范。建议统一用withFieldName表示对象某个字段的值应该满足约束用hasProperty表示对象是否具备某个属性。虽然 Hamcrest 自带的hasProperty用的是has前缀但业务匹配器大多是值约束用with更不容易引起混淆。第四步定期对匹配器库做减法。有些匹配器可能当年只是临时用了一下现在项目改版了根本没人调用。把所有生产无关的匹配器留在测试代码里本身就是一种负担。我用一个简单的策略每两个月跑一次静态分析或者按源代码检查工具把没有被引用的自定义匹配器清理掉。最后再给一个小技巧如果你的自定义匹配器要跟 Spring 的SpringBootTest、MockMvc 等框架一起用千万不要在匹配器里写数据库查询或远程调用之类的逻辑。匹配器应该是纯函数式的输入一个对象输出一个布尔值外加描述信息。一旦匹配器里混入 IO 操作测试的稳定性、速度都会受到影响踩过一次之后就再也不想碰这种写法了。我个人在实际项目里维护过几十个自定义匹配器最大的体会是自定义匹配器表面上是降低断言代码重复本质上是让测试失败信息变得可读、可定位、可检索。一个设计良好的自定义匹配器等于给你的测试代码增加了一个带说明书的断言词汇。随着匹配器库越来越丰富测试代码会越来越像业务规则本身而不是一堆assertThat的堆砌。如果你的项目里正好有一批字段多、规则复杂的对象需要反复校验不妨按这篇文章的思路试一下把一个最让你头疼的断言改写成自定义匹配器相信你会很快感受到这套玩法带来的改变。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑