MENU

关于Java单元测试中的Mock

2024 年 06 月 01 日 • 访问: 320 次 • Java

一、Mock的含义

Mock在英语的释义中,动词是嘲笑、嘲弄、模仿的意思,而形容词/名词的释义更贴合程序语言设计中Mock的含义,not real but appearing or pretending to be exactly like something,即不是真的但是假装它和某物很相似。

为什么要Mock呢,去假装模拟一个东西,但是又不是真的?这是因为很多时候,真实的东西难以获得,考虑到成本、效率的问题,我们常常希望在测试的较早阶段(比如开发者测试),能够低成本的模拟场景即可,而不必真正在真实场景中进行测试。

在程序开发中,Mock技术一般两种场景用的比较多,一种是单元测试的时候,另一种是接口测试的时候。接口测试,比如说后端开发中,常常用的Postman,可以理解成一种接口的Mock工具,我们可以人为的构造请求,模拟真实的请求,来进行接口测试。这里我们着重讲单元测试的Mock。

Mock分类

Mock可以分为两大类,一种是Mock静态方法,另一种是Mock实例方法。Mock实例方法通过使用@Mock注解类,而Mock静态方法,则需要使用mockStatic()方法,后面会结合代码实例讲解这两类Mock如何使用。常用的Java Mock框架Mockito在3.4.0之前的版本不支持mock静态方法,更高版本则提供了该特性

Mock的框架

Mock常用的框架比较多的是Mockito和PowerMock,由于SpringBoot的默认Mock框架就是Mockito,无需引入额外的三方件,根据最小依赖原理(奥卡姆剃刀),我们这里主要探讨Mockito框架。

Mockito 是一种 Java Mock 框架,主要是用来做 Mock 测试,它可以模拟任何 Spring 管理的 Bean、模拟方法的返回值、模拟抛出异常等等,同时也会记录调用这些模拟方法的参数、调用顺序,从而可以校验出这个 Mock 对象是否有被正确的顺序调用,以及按照期望的参数被调用。

像是 Mockito 可以在单元测试中模拟一个 Service 返回的数据,而不会真正去调用该 Service,这就是上面提到的 Mock 测试,也就是通过模拟一个假的 Service 对象,来快速的测试当前我想要测试的类。

目前在 Java 中主流的 Mock 测试工具有 Mockito、JMock、EasyMock等等,而 SpringBoot 目前内建的是 Mockito 框架。

Java的单元测试中,我们可以引入mockito框架来帮助我们实现mock,需要的pom依赖如下:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <scope>test</scope>
</dependency>

@Mock和@InjectMocks的区别

https://stackoverflow.com/questions/16467685/difference-between-mock-and-injectmocks/46727655

@Mock创建一个mock,@InjectMocks创建一个实例并且将注入由@Mock或者@Spy注解生成的mock。

注意,需要通过@Runwith(MockitoJUnitRunner.class)或者@Mockito.initMocks(this)来初始化这些mocks并注入。

如下所示,一般而言,是将需要测试的类,注解为@InjectMocks,将其依赖注解为@Mock

@RunWith(MockitoJUnitRunner.class)
public class SomeManagerTest {
 
    @InjectMocks
    private PlayGameService playGameService;
 
    @Mock
    private IUserDatabaseDao iUserDatabaseDao; // playGameService有调用数据库查询的DAO,将数据库查询的DAO注入到playGameService中

    //tests...
}

二、Mockito测试静态方法

在类HecWafUtill.java中有一个静态方法blockHecWaf,我们希望对该方法进行单元测试,即测试方法HecWafUtil.blockHecWaf()

这个Util类中实现了一个对一些恶意IP进行封堵的操作,但是需要调用第三方的WAF的接口,我们在单元测试中,不希望有连接三方系统的操作,因为环境无法保证网络是通的,其次我们也不希望单元测试对实际业务造成影响。所以这里我们可以对HttpRequest的post方法进行Mock

public class TestUtil {

     public static HttpResponse<String> blockHecWaf(String ip) {
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("ip", ip);
        HttpRequest request = HttpRequest.post("xxx/api/waf", requestBody);
        HttpResponse<String> res = request.execute("<service_id>");
        return res;
    }

}

UT代码示例

@RunWith(MockitoJUnitRunner.class)
public class MicroServiceUtilTest {
    @Mock
    private HttpRequest request;
 
    @Test
    public void test() {
        // 模拟静态HttpRequest
        MockedStatic<HttpRequest> httpRequestMockedStatic = mockStatic(HttpRequest.class);

        // 模拟HttpRequest的静态方法HttpRequest.post()
        httpRequestMockedStatic.when(() -> HttpRequest.post(anyString(), anyMap())).thenReturn(request);
        
        // 构造测试对象
        HttpResponse<String> httpResponse = new HttpResponse<>();
        httpResponse.setResult("test123");
        when(request.execute(anyString())).thenReturn(httpResponse);
 
        // 调用静态方法
        HttpResponse<String> response = MicroServiceUtil.blockHecWaf("192.168.5.5");
        Assert.assertEquals("test123", response.getResult());     
        httpRequestMockedStatic.close();
    }
}

除了使用 httpRequestMockedStatic.close() 来手动关闭资源,我们更多的是使用 try-with-resource 语法来实现资源的自动关闭,这种写法更加简洁安全。

public void test() {
    try(MockedStatic<HttpRequest> httpRequestMockedStatic = mockStatic(HttpRequest.class)) {
        ...
 
    }
}

三、Mockito测试实例方法

待测试的类TestService

@Component
public class TestService extends BaseHttpService<TestRequest> {
    @Autowired
    private TenantUtil tenantUtil;
 
    @Override
    public CommonResponse<String> execute(TestRequest TestRequest) throws HttpException {
        String tenantId = tenantUtil.getCurrentTenantId();
        CommonResponse<String> commonResponse = new CommonResponse<>().setResult(tenantId);
        return commonResponse;
    }
}

我们这里Mock了一下,当运行到tenantUtil.getCurrentTenantId()时直接返回租户的ID为2000,所以最终返回的commmonResponse的result的值就是2000

@RunWith(MockitoJUnitRunner.class)
public class TestServiceTest {
    @InjectMocks
    private TestService service;

    @Mock
    private TenantUtil tenantUtil;

    @Mock
    private TestRequest testRequest;
 
    @Test
    public void test() throws Exception {
        when(tenantUtil.getCurrentTenantId()).thenReturn("2000");
        CommonResponse<String> response = service.execute(testRequest);
        Assert.assertEquals("2000", response.getResult());
    }
}

四、测试void方法

有一个方法,他的返回值是void,也就是说,我们无法对方法的返回值进行断言。那么我们要如何测试void方法呢?参考这篇博客里的说法,我觉得说的挺好的

https://blog.csdn.net/dongjian764/article/details/17303279

首先站在测试角度来分析void方法, 每一个方法的设计都有特定功能的,在下面列一些void方法可能作的事:在方法体内对私有的对象实例进行属性设置、作了日志输出、调用另外实例方法、初始化某个类型变量(可能是私有的或者全局的)、调用了其他微服务的接口、进行了数据库的增删改查的操作、对一些异常情景抛出了异常。

虽然我们获取不到void方法最终的返回值,但是我们可以对void方法内部的行为的影响(也叫做副作用)去做测试分析。具体来说,我们可以使用如下几个方法来对void方法进行单元测试:

  1. 获取实例对象,验证其变化是否符合预期
  2. 使用verify()方法,验证void函数内部某个方法执行的次数。
  3. 使用doAnswer()方法,验证void函数内部调用某方法时的入参
  4. 使用AgumentCaptor捕获调用某些方法时的入参,验证入参在是否符合预期
  5. 使用assertThrows,验证void方法中的异常分支

现在举一些代码实例来进行说明:

获取实例对象,验证其变化是否符合预期

最经典的就是对Java bean的set()方法的单元测试(虽然我个人觉得对bean的单元测试没有什么意义,但是行覆盖率要统计呀),这里我们想覆盖测试到这个bean的setAgentId方法,它虽然没有返回值,但是它设置了AssetHost对象里面的agentId的值,所以我们只需要验证它set的值是否符合预期就行,这个是不是很好理解检验“副作用”的这个意思。

待测试的bean

public class AssetHost {
    private String agentId;
 
    public String getAgentId() {
        return agentId;
    }
 
    public void setAgentId(String agentId) {
        this.agentId = agentId;
    }
}

UT代码

@Test
public void testAssetHost() {
    AssetHost assetHost = new AssetHost();
    assetHost.setAgentId("da9e46c7-e275-4286-9ce9-8ecaa3864530");
    String hostId = assetHost.getAgentId();
 
    assertEquals("da9e46c7-e275-4286-9ce9-8ecaa3864530", hostId);
}

使用verify()方法,验证void函数内部某个方法执行的次数

就比如这个测试方法对iScoreDao这个数据库DAO,执行数据插入更新次数进行校验

  • iScoreDao.batchInsertOrUpdateScore()会执行3次
@RunWith(MockitoJUnitRunner.class)
public class SynJobTest {
    @InjectMocks
    private SynJob synJob;
 
    @Test
    public void testSynScore() throws JobExecutionException {
        try (MockedStatic<MicroServiceUtil> microServiceUtilMockedStatic = mockStatic(MicroServiceUtil.class);

            .......

            synJob.execute(jobExecutionContext);
            verify(iScoreDao, times(3)).batchInsertOrUpdateScore(anyList());
        }
    }
}

使用doAnswer()方法,验证void函数内部调用某方法时的入参

待测试的类

public class Sample {
    public void upsertDetail(String summary) {
        List<Detail> details = buildDetail(summary);
        iDetailDao.batchUpsert(details);
    }

}

UT代码,使用doAnswer可以拿到方法执行到iDetailDao.batchUpsert的入参details,进而内部的元素进行断言,进而校验是否符合预期值

@RunWith(MockitoJUnitRunner.class)
public class SampleTest {

    @InjectMocks
    private Sample sample;

    @Test
    public void test() {

        doAnswer(invocationOnMock -> {
            List<Detail> details = invocationOnMock.getArgument(0); // 获取入参,跟数组的一样,起始计数为0,即获取最开始的那个入参
            Detail detail = details.get(0); // 取数组首项
            assertEquals("Success", detail.getResult()); // 预期 result = Success
            return null;
        }).when(iDetailDao).batchUpsert(any());

        sample.upsertDetail("summary string");

    }
}

使用AgumentCaptor捕获调用某些方法时的入参,验证入参在是否符合预期

参考ArgumentCaptor使用方法:
https://blog.csdn.net/hotdust/article/details/51417163

待测试的类

public class Sample {
    public void upsertDetail(String summary) {
        List<Detail> details = buildDetail(summary);
        iDetailDao.batchUpsert(details);
    }

}

使用参数捕获器验证入参,ArgumentCaptor要结合verify()使用,和doAnswer()...when()方法不同,需要将verify放在sample.upsertDetail后面执行。

argumentCaptor.getValue()方法可以捕获到到最后一次调用方法的入参,从而进行断言,验证参数是否符合预期。若有多次方法的调用,可以使用getAllValues()方法获取参数列表。具体ArgumentCaptor可参阅网上的资料。

@RunWith(MockitoJUnitRunner.class)
public class SampleTest {
    @InjectMocks
    private Sample sample;

    @Test
    public void test() {

        sample.upsertDetail("Summary string");

        ArgumentCaptor<List<Detail>> argumentCaptor = ArgumentCaptor.forClass(List.class);
        verify(iDetailDao, times(1)).batchUpsert(argumentCaptor.capture());

        List<Detail> details = argumentCaptor.getValue();
        assertEquals("192.168.X.X", details.get(0).getIp());
    }
}

使用assertThrows,验证void方法中的异常分支

public class Sample {
    public void upsertDetail(String summary) throws HttpException {
        List<Detail> details = buildDetail(summary);
        iDetailDao.batchUpsert(details);
    }

}

假设传入的某些字符串会导致upsertDetail方法会抛出HttpException异常,我们使用assertThrows断言可以检查特定情况下的的方法是否有抛出特定异常,从而达到验证void方法异常分支的目的。

@RunWith(MockitoJUnitRunner.class)
public class SampleTest {
    @InjectMocks
    private Sample sample;

    @Test
    public void test() throws HttpException {
        HttpException httpException = assertThrows(HttpException.class,
                () -> sample.upsertDetail("<some string cause exception>"));
        assertEquals("message", httpException.getMessage());
        assertEquals(404, httpException.getHttpStatus());
    }
}
最后编辑于: 2024 年 10 月 15 日
返回文章列表 打赏
本页链接的二维码
打赏二维码