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 的理解。

相关推荐
青云计划10 小时前
知光项目知文发布模块
java·后端·spring·mybatis
Victor35611 小时前
MongoDB(9)什么是MongoDB的副本集(Replica Set)?
后端
Victor35611 小时前
MongoDB(8)什么是聚合(Aggregation)?
后端
yeyeye11112 小时前
Spring Cloud Data Flow 简介
后端·spring·spring cloud
Tony Bai13 小时前
告别 Flaky Tests:Go 官方拟引入 testing/nettest,重塑内存网络测试标准
开发语言·网络·后端·golang·php
+VX:Fegn089513 小时前
计算机毕业设计|基于springboot + vue鲜花商城系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
程序猿阿伟13 小时前
《GraphQL批处理与全局缓存共享的底层逻辑》
后端·缓存·graphql
小小张说故事13 小时前
SQLAlchemy 技术入门指南
后端·python
识君啊13 小时前
SpringBoot 事务管理解析 - @Transactional 的正确用法与常见坑
java·数据库·spring boot·后端
想用offer打牌14 小时前
MCP (Model Context Protocol) 技术理解 - 第五篇
人工智能·后端·mcp