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

作为 Java 开发者,日常写单元测试时大概率会用到 Mockito,而 eq() 匹配器是最常用的工具之一。但最近在 Mock Redis 的 setIfAbsent 方法时,踩了一个极难排查的坑 ------用eq()匹配器给setIfAbsent 方法打桩时传递的参数与实际调用参数一模一样,但却匹配失效。

这篇文章会从实际踩坑场景出发,拆解 Mockito eq() 匹配的底层逻辑,顺带回顾一下 Java 中 equalshashCode 的核心契约,以及为什么重写 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,其中 wantedInteger 2;而实际传入的参数是 Long 2L

调用 Integer.equals(Long) 时,Integerequals 方法会先校验类型:

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; 
}

此时 wantedLong 2L,和实际参数类型一致,Long.equals(Long) 返回 true,匹配成功。

三、延伸:equals 和 hashCode 的核心契约

从上面的踩坑场景可以看出,equals 方法的类型校验是核心。而在 Java 中,equalshashCode 是一对「绑定契约」,必须同时重写。

3.1 先明确默认行为

  • equals:Object 类的默认实现是比较对象的内存地址(等价于 ==);
  • hashCode:Object 类的默认实现返回对象内存地址的哈希值。

3.2 为什么重写 equals 必须重写 hashCode?

Java 官方定义了 equalshashCode 的三大契约:

  1. 若两个对象 equals 返回 true,则它们的 hashCode 必须相等;
  2. 若两个对象 hashCode 相等,equals 不一定返回 true(哈希碰撞);
  3. 若两个对象 equals 返回 falsehashCode 可以相等也可以不等(推荐不等,减少碰撞)。

违反契约的后果:哈希集合(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 时,u1u2hashCode 不同,会被散到不同桶,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 方法,intlong 类型即使值相同,equals 也会返回 false,必须保证匹配器类型和实际参数类型一致;

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

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

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

相关推荐
国思RDIF框架1 小时前
RDIFramework.NET CS 敏捷开发框架 V6.3 版本重磅发布!.NET8+Framework双引擎,性能升级全维度进化
后端·.net
心在飞扬1 小时前
ReRank重排序提升RAG系统效果
前端·后端
喝茶与编码1 小时前
Python异步并发控制:asyncio.gather 与 Semaphore 协同设计解析
后端·python
不早睡不改名1 小时前
网络编程基础:从BIO到NIO再到AIO(一)
后端
开源之眼1 小时前
《github star 加星 Taimili.com 艾米莉 》为什么Java里面,Service 层不直接返回 Result 对象?
java·后端·github
心在飞扬1 小时前
RAPTOR 递归文档树优化策略
前端·后端
zone77392 小时前
003:RAG 入门-LangChain 读取图片数据
后端·python·面试
心在飞扬2 小时前
LangChain Parent Document Retriever (父文档检索器)
后端
zone77392 小时前
002:RAG 入门-LangChain 读取文本
后端·算法·面试
用户8356290780512 小时前
在 PowerPoint 中用 Python 添加和定制形状的完整教程
后端·python