Mockito eq () 匹配失败?Java 参数传递的坑 + equals/hashCode 重写必知

作为 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 的三大契约:

  1. 若两个对象 equals 返回 true,则它们的 hashCode 必须相等;
  2. 若两个对象 hashCode 相等,equals 不一定返回 true(哈希碰撞);
  3. 若两个对象 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 的最佳实践

  1. 字段选择:用业务唯一标识字段(如 id)参与计算,避免用可变字段(如 age);
  2. 工具推荐 :用 Lombok 的 @EqualsAndHashCode 自动生成,避免手写错误;
  3. 单元测试:验证「相等对象同 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
    }
}

四、总结

  1. Mockito eq () 匹配坑 :eq() 依赖 equals 方法,int 和 long 类型即使值相同,equals 也会返回 false,必须保证匹配器类型和实际参数类型一致;

  2. equals/hashCode 核心 :二者是哈希集合的契约基础,重写 equals 必须重写 hashCode,否则哈希集合会出现重复存储、查找失败等问题;

  3. 函数签名参数类型定义:枚举值和函数签名定义需一致,避免因自动类型转换导致的Bug,博主亲身验证因为这种问题导致的Bug真的很难找。

希望这篇文章能帮助各位伙伴避开 Mockito 类型匹配的坑,同时加深对 equals 和 hashCode 的理解。

相关推荐
金銀銅鐵42 分钟前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路1 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x2 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn3 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈4 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780515 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123455 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒5 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记5 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598785 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端