作为 Java 开发者,日常写单元测试时大概率会用到 Mockito,而 eq() 匹配器是最常用的工具之一。但最近在 Mock Redis 的 setIfAbsent 方法时,踩了一个极难排查的坑 ------用eq()匹配器给setIfAbsent 方法打桩时传递的参数与实际调用参数一模一样,但却匹配失效。
这篇文章会从实际踩坑场景出发,拆解 Mockito eq() 匹配的底层逻辑,顺带回顾一下 Java 中 equals 和 hashCode 的核心契约,以及为什么重写 equals 必须重写 hashCode。
一、背景:问题重现
最近公司为了提升大家的代码质量,在代码上传时加入单元测试的分支覆盖率检查,博主也是在放假前几天被 mentor 要求结合 Mocktio 框架给老代码补上单元测试。本文不再详细介绍 Mockito 框架,有兴趣的伙伴可以自查资料了解~
先看一段看似没问题的测试代码,核心是 Mock Redis 的 setIfAbsent 方法:
1.1 业务代码(简化版)
java
// Redis 工具类常量定义(注意:这里是 int 类型)
public class RedisUtil {
public static final int REDIS_EXPIRE_TWO_HOUR = 2;
}
// 账单导出业务逻辑
public class BillServiceImpl {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Boolean exportMonthBill(Long billId) {
String key = "bill:export:" + billId;
ValueOperations<String, Object> valueOps = redisTemplate.opsForValue();
BillExportStatusInfo statusInfo = BillExportStatusInfo(billId=1, status=1)
// 核心调用:第三个参数传入 int 类型的 2,方法参数声明为 long
return valueOps.setIfAbsent(
key,
statusInfo,
RedisUtil.REDIS_EXPIRE_TWO_HOUR, // int 2
TimeUnit.HOURS
);
}
}
1.2 测试代码(踩坑版)
java
@ExtendWith(MockitoExtension.class)
public class BillServiceImplTest {
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@InjectMocks
private BillServiceImpl billService;
@Test
void testExportMonthBill() {
// 1. Mock 调用链:让 redisTemplate 返回自定义的 valueOperations
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
// 2. 打桩 setIfAbsent 方法:第三个参数用 eq(2)(int 类型)
doReturn(true).when(valueOperations)
.setIfAbsent(
eq("bill:export:1"),
any(BillExportStatusInfo.class),
eq(RedisUtil.REDIS_EXPIRE_TWO_HOUR), // 问题根源:这里是 int 类型
eq(TimeUnit.HOURS)
);
// 3. 执行测试
Boolean result = billService.exportMonthBill(1L);
// 预期返回 true,实际在严格模式下报错
assertTrue(result);
}
}
1.3 问题现象
运行测试后直接报错:setIfAbsent ,在严格显示打桩的方法没有被使用,如果在宽松模式下得到的结果则为没有返回我们打桩的 true,而是返回了 Mockito 的默认值 false。
查看 Mockito 报错日志,核心提示:
plaintext
csharp
org.mockito.exceptions.misusing.PotentialStubbingProblem:
Strict stubbing argument mismatch. Please check:
- this invocation of 'setIfAbsent' method:
valueOperations.setIfAbsent(
"sc:v2:bill:export:month:customer:1",
BillExportStatusInfo(billId=1, status=1),
2L,
HOURS
);
-> at com.bw.iot.sc.v2.service.bill.BillServiceImpl.exportMonthBill(BillServiceImpl.java:168)
- has following stubbing(s) with different arguments:
1. valueOperations.setIfAbsent(
null,
null,
0L,
null
);
当时博主就懵圈了,函数入参值一模一样? 为什么 Mockito 没有调用博主定义好的函数?这个Mockito真不行,还是集成测试靠谱(不是)。下面我们来分析一下这个问题
二、底层拆解:eq () 匹配的核心逻辑
要理解这个问题,必须先搞懂 Mockito eq() 匹配器的工作原理。
2.1 eq () 的本质:调用对象的equals方法
Mockito 的 eq() 匹配器由 EqMatcher 实现,核心逻辑在 matches() 方法中:
java
// Mockito 内部 Equals 核心源码
public class Equals implements ArgumentMatcher<Object>, ContainsExtraTypeInfo, Serializable {
private final Object wanted; // 打桩时传入的参数(如 eq(2) 中的 2)
public Equals(Object wanted) {
this.wanted = wanted;
}
@Override
public boolean matches(Object actual) {
return Equality.areEqual(this.wanted, actual);
}
}
// Mockito 内部 Equality 核心源码
public final class Equality {
public static boolean areEqual(Object o1, Object o2) {
if (o1 == o2) {
return true;
} else if (o1 == null || o2 == null) {
return false;
} else if (isArray(o1)) {
return isArray(o2) && areArraysEqual(o1, o2);
} else {
return o1.equals(o2);
}
}
}
Mockito 通过将定义打桩函数时存下来的 Object 和实际调用的 Object 通过 equals 函数进行匹配,匹配成功则调用函数,否则报错或者返回默认值,取决于打桩函数的执行模式为严格模式或宽松模式。
验证:现在两个类,User类Car类,他们都有一个参数 Long id;现在我重写两个类的 equals 方法,只比较 id 的值是否一致,不做类型的校验,现在要测试一个方法,看是否能用 Mockito 打桩函数拦截上。
java
@Getter
@Setter
public class Car {
private Long id;
@Override
public boolean equals(Object obj) {
return ((User) obj).getId().equals(id);
}
}
@Getter
@Setter
public class User {
private Long id;
@Override
public boolean equals(Object obj) {
return ((User) obj).getId().equals(this.id);
}
}
// 要测试的方法
public class Motion {
private ExecuteSport executeSport;
public String verifyTest(){
User user = new User();
user.setId(1L);
return executeSport.doSport(user);
}
}
public class ExecuteSport {
public String doSport(Object object){
return "user running...";
}
}
java
@ExtendWith(MockitoExtension.class)
public class VerifyTest {
@InjectMocks
Motion motion;
@Mock
ExecuteSport executeSport;
@Test
void testVerifyTest(){
Car car = new Car();
car.setId(1L);
when(executeSport.doSport(eq(car))).thenReturn("car drive...");
System.out.println(motion.verifyTest());
}
}
运行结果:
说明 Equals 对象在运行期间 校验是否匹配只看对象的 equals 方法是否返回 True ,对于类型的比较则完全交给对象重写的 equals 进行判断。
那么问题来了,两边传入的参数一模一样,为什么匹配不上呢?
2.2 为什么两边函数的入参一样? eq () 却匹配不上?
通过博主和mentor的不懈努力,最终发现问题出在两个关键环节:
环节 1:Java 自动类型转换(int → long)
setIfAbsent 方法的第三个参数声明为 long 类型:
java
Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);
博主在定义枚举值的时候定义成了 int,当业务代码传入 int 类型的 时,Java 会触发「基本类型拓宽转换」,自动把 int 2 转成 long 2L 后传入方法 ------ 这个过程发生在 Mockito 拦截调用之前,而 Mockito 的 Equals 对象中的 wanted 值却在 eq() 中已完成定义,记录的类型为Integer。
环节 2:Integer.equals (Long) 必然返回 false
打桩时的 eq(2) 会创建 Equals,其中 wanted 是 Integer 2;而实际传入的参数是 Long 2L。
调用 Integer.equals(Long) 时,Integer 的 equals 方法会先校验类型:
java
// Integer 类的 equals 源码
public boolean equals(Object obj) {
if (obj instanceof Integer) { // Long 不是 Integer 的实例
return value == ((Integer)obj).intValue();
}
return false;
}
类型不匹配直接返回 false,导致 eq() 匹配失败。
2.3 修复方案:匹配正确的类型
只需把打桩的枚举值定义为 long 类型就可以匹配成功:
java
// Redis 工具类常量定义
public class RedisUtil {
public static final long REDIS_EXPIRE_TWO_HOUR = 2;
}
此时 wanted 是 Long 2L,和实际参数类型一致,Long.equals(Long) 返回 true,匹配成功。
三、延伸:equals 和 hashCode 的核心契约
从上面的踩坑场景可以看出,equals 方法的类型校验是核心。而在 Java 中,equals 和 hashCode 是一对「绑定契约」,必须同时重写。
3.1 先明确默认行为
equals:Object 类的默认实现是比较对象的内存地址(等价于==);hashCode:Object 类的默认实现返回对象内存地址的哈希值。
3.2 为什么重写 equals 必须重写 hashCode?
Java 官方定义了 equals 和 hashCode 的三大契约:
- 若两个对象
equals返回true,则它们的hashCode必须相等; - 若两个对象
hashCode相等,equals不一定返回true(哈希碰撞); - 若两个对象
equals返回false,hashCode可以相等也可以不等(推荐不等,减少碰撞)。
违反契约的后果:哈希集合(HashMap/HashSet/HashTable)行为异常。
反例:只重写 equals 不重写 hashCode
java
class User {
private Long id;
private String name;
// 只重写 equals,按 id 判断相等
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
// 未重写 hashCode,使用 Object 的默认实现
// @Override
// public int hashCode() { return Objects.hash(id); }
public User(Long id, String name) {
this.id = id;
this.name = name;
}
}
// 测试 HashSet 去重
public class HashCodeTest {
public static void main(String[] args) {
User u1 = new User(1L, "张三");
User u2 = new User(1L, "张三");
Set<User> set = new HashSet<>();
set.add(u1);
set.add(u2);
// 预期 size=1,实际 size=2(因为 hashCode 不同,被散到不同桶)
System.out.println(set.size());
}
}
原理:哈希集合的工作流程
Hash集合先调用对象的 hashCode 方法得到 Hash 值,只重写 equals 时,u1 和 u2 的 hashCode 不同,会被散到不同桶,equals 方法根本不会被调用,导致重复存储。
3.3 重写 equals/hashCode 的最佳实践
- 字段选择:用业务唯一标识字段(如 id)参与计算,避免用可变字段(如 age);
- 工具推荐 :用 Lombok 的
@EqualsAndHashCode自动生成,避免手写错误; - 单元测试:验证「相等对象同 hash」「不等对象不同 hash(尽量)」。
正确示例(Lombok 版)
java
import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
@EqualsAndHashCode(of = {"id"}) // 只按 id 生成 equals/hashCode
class User {
private Long id;
private String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
}
// 测试:set.size() 会输出 1(正确去重)
正确示例(手写版)
java
class User {
private Long id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id); // 只基于 id 计算 hash
}
}
四、总结
-
Mockito eq () 匹配坑 :
eq()依赖equals方法,int和long类型即使值相同,equals也会返回 false,必须保证匹配器类型和实际参数类型一致; -
equals/hashCode 核心 :二者是哈希集合的契约基础,重写
equals必须重写hashCode,否则哈希集合会出现重复存储、查找失败等问题; -
函数签名参数类型定义:枚举值和函数签名定义需一致,避免因自动类型转换导致的Bug,博主亲身验证因为这种问题导致的Bug真的很难找。
希望这篇文章能帮助各位伙伴避开 Mockito 类型匹配的坑,同时加深对 equals 和 hashCode 的理解。