侧边栏壁纸
博主头像
Terry

『LESSON 5』

  • 累计撰写 90 篇文章
  • 累计创建 21 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录

单元测试

Terry
2022-03-29 / 0 评论 / 0 点赞 / 599 阅读 / 10,842 字 / 正在检测是否收录...

单元测试

什么是单元测试

单元测试,就看字面的意思,简单来说就是测试的目标是一个单元,所以叫做单元测试。

举个例子,比如生产汽车,汽车是由很多部件组成的,比如刹车部件,轮胎,发动机等。而厂家生产汽车不是等一台汽车生产完毕后,才对汽车进行测试。一般来说,厂家会把汽车各个部件进行单独的测试,比如刹车部件测试,轮胎质量测试,发动机性能测试,座椅调节测试等,所有部件都单独测试通过后,再进行整车测试,通过测试后才算通过质量检测。

如果生产完毕后才进行测试,就像我们开发的时候,同学们开发各自编写了模块1,模块2等等,最后相互串起来,组成整个功能,整体测试交由给QA。

我们一般情况下都会做简单的测试,没有完整一套单元测试,大部分情况下都是我们逼不得已,项目进度太赶了,想完成一套单元测试基本不太可能。其实也算我们项目控制出了问题吧,虽然我们现在省下了时间,但是未来我们可能用更多时间来测试此项目。其实最重要的是,如果自己负责的模块到处是bug,就感觉不太好意思了。所以个人还是提倡能完善单元测试尽量完善。

写单元测试的目的

写单元测试的目的是什么,为什么要有单元测试这东西?其实总结起来就两个目的:

  1. 保证代码模块的行为和结果和我们预期是一致的
  2. 保证代码模块的行为和结果和我们预期一直是一致的

为啥这里的第二点强调一直两个字?其实我们大部分程序员都能达到第一点,写好代码后进行简单测试,再由测试中出现的问题再进行解决问题。但其实作用也仅仅在我们上线前的那一刻,如果单元测试总分100分,那我们这种测试只能拿到20分,并且随着时间的推移,我们的单元测试可能变得不可能,最后变成了0分。

为什么这么说呢?

  1. 比方说我们查询订单数据,这个查询是强依赖数据库的,但是往往由于数据库的数据的变动和不确定性导致单元测试失败,这显然是个不太合理并不友好的单元测试。毕竟我们只想测试获取订单这个方法是否符合预期为目标,但是由于数据库的数据变动这个外部原因导致单元测试失败,我们不敢说是获取订单这个方法出问题。所以一个好的单元测试,必须维持数据健壮性,是个数据健壮的单元测试。
  2. 比方说我们测试下单,而订单表中有个字段是声明为唯一的,这时候我们单元测试运行是没问题的。但是再次运行单元测试的时候,就会报错了。这个错误能说明是因为我们程序有bug导致的吗?明显不能。主要原因还是因为外部因素导致我们单元测试失败。
  3. 单元测试命名问题。有些单元测试命名为test01 test02,没有规范化或者解释单元测试的作用。有时候当自己想回归测试用例进行单元测试的时候,看到这个测试名叫test01,自己都忘记单元测试的意图。比如别人接收你的项目时,也是不了解你这个单元测试是什么,有可能造成别人自己又写了一套或者要看下你测试代码的意图。
  4. 比方说我们现在有一个下单的单元测试,需要调用冻结库存的方法,从而调用了库存项目提供的dubbo远程调用接口来冻结库存。这个单元测试主要的作用其实是想测试整个下单流程是否符合预期并且一直符合预期结果,但是最终单元测试的结果是失败了,这时候能证明是我们程序写得有问题吗?明显不能的,因为有可能是库存那边处理有问题导致单元测试失败。所以这也不是一个健壮的单元测试,因为违背了接口健壮性的规范,编写的单元测试会因为外部变化而变化。

如果我们能够解决以上的问题,能够自信的说这个错误是我们自己代码导致的而不是因为外部各种原因导致的,才算是一个健壮的单元测试。因此,我们可以使用某些框架帮我们解决这类问题。

所以以下我介绍一些测试框架,包括单元测试使用到的JUnit 5mockito,基准测试使用到的JMH

JUnit 5

什么是JUnit 5?

JUnit 5其实就是单元测试框架。只是JUnit 5和以往的JUnit框架有很大不同,JUnit 5由三个不同的子项目组成:

  • JUnit Platform:从名字上看,JUnit 5 不仅仅只是想简单作为一个测试框架,更多希望是能作为一个测试平台,不仅支持Junit自制的测试引擎,其他测试引擎也都可以接入这个平台进行对接和执行
  • JUnit Jupiter:JUnit Jupiter提供了JUnit5的新的编程模型,是JUnit5新特性的核心。内部包含了一个测试引擎,用于在Junit Platform上运行
  • JUnit Vintage:由于JUint已经发展多年,为了照顾老的项目,JUnit Vintage提供了兼容JUnit4.x,Junit3.x的测试引擎。其实也可以看作是基于JUnit platform实现的接入规范

支持的Java版本

JUnit 5 在运行时需要 Java 8(或更高版本)

使用Junit 5进行测试

简单JUnit测试

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class FirstJUnitTests {

    @Test
    void firstTest() {
        String msg = "Hello World";
        assertEquals(msg,"Hello World");
    }

    @Test
    void firstErrorTest() {
        String msg = "Hello World";
        assertEquals(msg,"Hello");
    }

}

以上是个简单的JUnit测试。@Test注解代表是个测试方法,assertEquals是个断言方法,比较是否等于自己预期结果。
断言的方法不仅仅是assertEquals,想了解更多可以查看org.junit.jupiter.api.Assertions类。

生命周期

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;

/**
 * 试下多线程下生命周期
 */
@Slf4j
class LifecycleTests {

    @BeforeAll
    static void initAll() {
        //在所有测试方法运行前运行
        log.info("I'm BeforeAll");
    }

    @BeforeEach
    void init() {
        //每个测试方法运行前运行
        log.info("I'm BeforeEach");
    }

    @AfterEach
    void tearDown() {
        //每个测试方法运行完毕后运行
        log.info("I'm AfterEach");
    }

    @AfterAll
    static void tearDownAll() {
        //在所有测试方法运行完毕后运行
        log.info("I'm AfterAll");
    }

    @Test
    @Disabled
    @DisplayName("Ignore the test")
    public void disabledTest() {
        //这个测试不会运行
        log.info("This test will not run");
    }

    @Test
    @DisplayName("Test Methods 1+1")
    public void addFirstTest() {
        log.info("Running test 1+1");
        Assertions.assertEquals(2,1+1);
    }

    @Test
    @DisplayName("Test Methods 2+2")
    public void addSecondTest() {
        log.info("Running test 2+2");
        Assertions.assertEquals(4,2+2);
    }
}

输出:

17:41:43.823 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm BeforeAll
public void com.example.d4c.junit5.LifecycleTests.disabledTest() is @Disabled
17:41:43.854 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm BeforeEach
17:41:43.854 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - Running test 1+1
17:41:43.869 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm AfterEach
17:41:43.869 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm BeforeEach
17:41:43.869 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - Running test 2+2
17:41:43.869 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm AfterEach
17:41:43.869 [ForkJoinPool-1-worker-1] INFO com.example.d4c.junit5.LifecycleTests - I'm AfterAll

由输出可得,一次测试的生命周期为:BeforeAll->BeforeEach->test01->AfterEach->BeforeEach->test02->AfterEach->AfterAll

执行顺序

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;

import java.util.concurrent.TimeUnit;

@Slf4j
@DisplayName("方法执行顺序控制")
@TestMethodOrder(value = MethodOrderer.OrderAnnotation.class)
public class OrderTests {

    private static String orderSn;

    private static Long userId;

    @BeforeAll
    static void init() {
        orderSn = "SO123456789";
        userId = 404L;
        log.info("初始化数据成功");
    }

    @SneakyThrows
    @AfterEach
    void afterEach() {
        TimeUnit.MILLISECONDS.sleep(100L);
        log.info("每个方法后做某些处理");
    }


    @Test
    @Order(1)
    @DisplayName("成单")
    void placeTest() {
        log.info("成单,订单号:{},用户ID:{}", orderSn, userId);
    }

    @Test
    @Order(2)
    @DisplayName("支付订单")
    void payTest() {
        log.info("支付订单,订单号:{},用户ID:{}",orderSn, userId);
    }

    @Test
    @Order(3)
    @DisplayName("订单发货")
    void deliveryTest() {
        log.info("订单发货,订单号:{},用户ID:{}",orderSn, userId);
    }

    @Test
    @Order(4)
    @DisplayName("确认订单")
    void confirmTest() {
        log.info("确认订单,订单号:{},用户ID:{}",orderSn, userId);
    }

    @Test
    @Order(5)
    @Disabled
    @DisplayName("取消订单")
    void cancelTest() {
        log.info("取消订单,订单号:{},用户ID:{}",orderSn, userId);
    }

通过@TestMethodOrder(value = MethodOrderer.OrderAnnotation.class)以及@Order可以控制方法的执行顺序。此时不可以设置方法
并发运行,否者指定顺序执行会失效。如以上的例子,我通过此注解对订单整个流程进行测试,而不需要每次都要启动一次,每次修改订单号和用户ID进行测试

重复测试

在某些场景中,我们可以对一个测试运行多次,JUnit提供了重复测试的方法。下面我们看看如何使用。

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@DisplayName("重复测试")
public class RepeatedTests {

    @RepeatedTest(10)
    void firstTest() {
        log.info("first test");
    }

    @RepeatedTest(10)
    @DisplayName("repetition {currentRepetition} of {totalRepetitions}")
    void nameTest(RepetitionInfo repetitionInfo) {
        log.info("I'm test {}",repetitionInfo.getCurrentRepetition());
    }

    @RepeatedTest(5)
    void repeatedTestWithRepetitionInfoTest(RepetitionInfo repetitionInfo) {
        assertEquals(5, repetitionInfo.getTotalRepetitions());
    }

    @RepeatedTest(value = 1, name = "{displayName} {currentRepetition}/{totalRepetitions}")
    @DisplayName("Repeat!")
    void customDisplayNameTest(TestInfo testInfo) {
        assertEquals("Repeat! 1/1", testInfo.getDisplayName());
    }

    @RepeatedTest(value = 1, name = RepeatedTest.LONG_DISPLAY_NAME)
    @DisplayName("Details")
    void customDisplayNameWithLongPattern(TestInfo testInfo) {
        assertEquals("Details :: repetition 1 of 1", testInfo.getDisplayName());
    }
}

通过@RepeatedTest注解可以完成重复测试的工作,其中value属性可以设置重复的次数,name属性可以自定义重复测试的显示名称。
显示名称可以由占位符和静态文本组合,目前支持的几种占位符包括:{displayName}: 显示名 - {currentRepetition}: 当前重复次数 - {totalRepetitions}: 总重复次数
当然,@RepeatedTest注解也包含了几种默认的名称格式,如上面例子中的RepeatedTest.LONG_DISPLAY_NAME。如果想在方法中获取到当前循环的相关信息,可以将RepetitionInfo
实例注入到相关方法中,如上面repeatedTestWithRepetitionInfoTest方法例子。

参数化测试

参数化测试可以使我们通过传入不同的参数进行多次单元测试,他和普通的单元测试差不多,参数由@Test更换为@ParameterizedTest

我们先看看一个简单得参数化测试例子:

    @ParameterizedTest
	@NullSource
	@EmptySource
	@NullAndEmptySource
	@ValueSource(strings = {"TEST_1", "TEST_2", "TEST_3"})
	@DisplayName("第一次测试")
    void firstTest(String candidate) {
         log.info(candidate);
    }

再看看输出:

param_test

由以上截图看出,一共进行了5次单元测试,入参分别为null TEST_1 TEST_2 TEST_3 。在某些场景下,我们定义参数可以获得不同的测试结果。

  • @ParameterizedTest:声明为参数化测试
  • @NullSource:添加为null参数。参数类型为非基本类型才能使用
  • @EmptySource:添加为空的参数。参数类型为非基本类型才能使用
  • @NullAndEmptySource:添加null和空两个参数。参数为非基本类型才能使用
  • @ValueSource:定义入参,支持的类型包括shortbytesintlongfloatdoublecharsbooleanstringclass

以上注解基本上可以满足我们日常参数化测试,JUnit还贴心地为我们准备@NullSource@EmptySource@NullAndEmptySource注解,进行对空参数进行测试。

参数化测试也支持枚举作为参数进行测试,如以下例子:

    @ParameterizedTest
    @EnumSource(EnumOrderType.class)
//    @EnumSource(names = {"NORMAL"})
    @DisplayName("枚举测试")
    void enumSourceTest(EnumOrderType type) {
        log.info(type.name());
    

@EnumSource注解可以循环我们指定的枚举类,也可以通过names参数指定具体枚举 。

大家可以发现,以上的注解都只是对于单个入参进行参数化测试。但是对于某些场景需要传多个参数,JUnit也提供了注解进行多个入参的参数化测试。如以下例子:

    @ParameterizedTest
    @DisplayName("本地方法提供多个参数")
    @MethodSource("stringIntAndListProvider")
    void testWithMultiArgMethodSource(String str, int num, List<String> list) {
        assertEquals(5, str.length());
        assertTrue(num >= 1 && num <= 2);
        assertEquals(2, list.size());
    }

    static Stream<Arguments> stringIntAndListProvider() {
        return Stream.of(
                arguments("apple", 1, Arrays.asList("a", "b")),
                arguments("lemon", 2, Arrays.asList("x", "y"))
        );
    }

通过@MethodSource注解指定了某个方法,然后这个方法返回的数据类型是个Stream<Arguments>。然后我们可以通过Arguments.arguments()方法自定义入参,这样我们测试的时候就能测试多个入参的参数化测试。

除了自定义一个构建参数化的方法,还有另外一种方法进行多参数测试。如以下例子:

    @ParameterizedTest
    @CsvSource({
            "apple,         1",
            "banana,        2",
            "lemon,         3",
            "strawberry,    700000"
    })
    @DisplayName("CSV注解参数数据测试")
    void testWithCsvSource(String fruit, int rank) {
        assertNotNull(fruit);
        assertNotEquals(0, rank);
    }


    @ParameterizedTest
    @DisplayName("从CSV文件导入数据")
    @CsvFileSource(resources = "/fruit.csv", numLinesToSkip = 1)
    void testWithCsvFileSourceFromClasspath(String fruit, int rank) {
        assertNotNull(fruit);
        assertNotEquals(0, rank);
    }

通过@CsvSource注解构建csv格式的入参,或者说我们可以新建一个csv文件放在resources下,然后通过@CsvSource的参数resources配置文件路径,读取csv文件中的内容。记得需要加上参数numLinesToSkip = 1,跳过第一行。

临时文件夹

有时候我们需要测试导出数据文件,一般情况下我们执行方法导出文件,查看数据正确性,最后需要把文件手动删除掉。Junit提供了创建临时文件夹方法,并且在测试执行后会删除临时文件夹。如以下例子:

@Slf4j
public class TempDirTests {

    @TempDir
    Path tempDir;

    @Test
    void writeItemsToFile() throws IOException {
        Path file = tempDir.resolve("test.txt");
        log.info(file.toFile().getAbsolutePath());
        String data = "a,b,c";
        FileUtils.write(file.toFile(), data, StandardCharsets.UTF_8);
        String originInfo = Hashing.sha256().newHasher().putBytes(data.getBytes(StandardCharsets.UTF_8)).hash().toString();
        String fileInfo = Hashing.sha256().newHasher().putBytes(FileUtils.readFileToByteArray(file.toFile())).hash().toString();
        assertEquals(originInfo, fileInfo);
    }

}

单元测试配置

在springboot项目中,通常会有一个resources包,下面放了一些资源文件,包括了application.yml等配置文件。单元测试其实也是支持文件配置的。我们可以到test包下面添加resources包,然后建立配置文件junit-platform.properties,配置内容如下所示:

# 并行开关true/false
junit.jupiter.execution.parallel.enabled=true
# 方法级多线程开关 same_thread/concurrent
junit.jupiter.execution.parallel.mode.default = same_thread
# 类级多线程开关 same_thread/concurrent
junit.jupiter.execution.parallel.mode.classes.default = same_thread

# 并发策略有以下三种可选:
# fixed:固定线程数,此时还要通过junit.jupiter.execution.parallel.config.fixed.parallelism指定线程数
# dynamic:表示根据处理器和核数计算线程数
# custom:自定义并发策略,通过这个配置来指定:junit.jupiter.execution.parallel.config.custom.class
junit.jupiter.execution.parallel.config.strategy = fixed

# 并发线程数,该配置项只有当并发策略为fixed的时候才有用
junit.jupiter.execution.parallel.config.fixed.parallelism = 5
  • junit.jupiter.execution.parallel.enabled:是否打开并行操作
  • junit.jupiter.execution.parallel.mode.default:方法级并行开关
  • junit.jupiter.execution.parallel.mode.classes.default:类级并行开关
  • junit.jupiter.execution.parallel.config.strategy:并行策略
    • fixed:固定线程数,此时还要通过junit.jupiter.execution.parallel.config.fixed.parallelism指定线程数
    • dynamic:表示根据处理器和核数计算线程数
    • custom:自定义并发策略,通过这个配置来指定:junit.jupiter.execution.parallel.config.custom.class
  • junit.jupiter.execution.parallel.config.fixed.parallelism:并行线程数,该配置项只有当并发策略为fixed的时候才有用

spring-test

@Rollback

当我们进行数据库操作的时候,有时候不想插入或者更新测试数据,仅仅只是测试整个流程上是否有误。如果插入或者更新数据,有可能把原来比较干净的数据污染了,或者说需要手动把原来数据修正。还好,spring test提供了一个注解,用于管理事务,测试方法完成后是否回滚。这个注解是@Rollback 。当然,在spring test中也存在不少好用的测试注解,大家有空可以看下spring-test

下面是使用@Rollback例子:

@Rollback
@Transactional
@SpringBootTest
class OrderServiceImplTest {

    @Autowired
    private OrderService orderService;

    @Test
    void insertTest() {
        Order order = new Order();
        order.setUserId(0L);
        order.setOrderSn("TEST321");
        orderService.place(order);
    }
  
}

以上只是简单往数据库插入一条数据操作测试,但是加上了@Transactional@Rollback注解。当测试运行完,就会进行事务回滚,数据库中不会存在此数据。注意,@Rollback只用于spring测试中,并且配合@Transactional使用。

  • @Transactional:开启事务
  • @Rollback:测试运行完后回滚事务

这里提一下,使用JUnit 5进行springboot测试只需要加@SpringBootTest注解即可,Junit 4还需要加上@RunWith(SpringRunner.class),因为@SprigBootTest注解中包含了与其等效的@ExtendWith(SpringExtension.class)注解。

MockMvc

当我们需要提供REST API接口,然后需要进行自测时,大家可能有两种测试方式:

  1. 通过启动项目,然后使用postman进行接口测试,或者使用IDEA支持建里.http文件进行接口测试
  2. test下建里测试类并且通过注入controller进行单元测试

第一种方式进行了真实http请求,但是没有在我们test下测试,如果其他同事想测试就需要导出postman文件然后再进行测试,比较繁琐;第二种方式只是使用了Spring注入了Bean,进行Controller中的方法调用,没有模拟真实http请求。那如果想进行单元测试,并且模拟真实http请求,有没有办法?这时候我们可以使用MockMvc

MockMvc是由spring-test包提供,实现了对Http请求的模拟,能够直接使用网络的形式进行测试。同时提供了一套验证的工具,结果的验证十分方便。我们看看下面例子:

@SpringBootTest
@AutoConfigureMockMvc
public class ControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void loadTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/order/load")
                        .contentType(MediaType.ALL)
                        .param("id", "1"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.content().json("{\"id\":1,\"orderSn\":\"2222333444\"}"))
                .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.is(1)))
                .andExpect(MockMvcResultMatchers.jsonPath("$.orderSn", Matchers.notNullValue()));
    }
}

结果输出:

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /order/load
       Parameters = {id=[1]}
          Headers = [Content-Type:"*/*;charset=UTF-8"]
             Body = null
    Session Attrs = {}

Handler:
             Type = com.example.d4c.controller.OrderController
           Method = com.example.d4c.controller.OrderController#load(long)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"userId":40026959,"orderSn":"2222333444","topOrderSn":"2222333444","orderType":52,"orderStatus":10,"originalPrice":1,"finalPrice":1,"deductionPrice":0,"remark":null,"supplierId":1,"timeoutCancelTime":"2021-08-03T07:25:27.000+00:00","stockCode":"","markBits":"","bizType":"","createTime":"2021-08-03T07:25:34.000+00:00","updateTime":"2021-10-26T12:04:30.000+00:00","deleteMark":0}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

首先我们添加@AutoConfigureMockMvc注解,作用是自动配置MockMvc。然后通过perform()方法配置请求,andDo()执行我们想要的一些操作,比如输出详情信息。通过andExpect()可以判断是否符合我们的预期,例子中可以判断返回信息是否符合我们预期,甚至结果中某个结果是否符合我们预期。IDEA还能帮忙检查JSONPath正确性。

Mockito

前言

   @Test
    void getUsersTest() {
        boolean result = stockFacade.lock("SO123");
        Assertions.assertTrue(result);
    }

上面这段单元测试,调用了stockFacade的lock()方法,该方法里面调用了其他服务提供的dubbo远程调用接口,请求获得对应的用户信息。单元测试是要验证行为符合预期并且一直符合预期,但是上面的单元测试符合并且一直符合我们的预期结果吗?

显然,是不可以的。当我们判断一个单元测试好不好的一种方式是问自己:

“如果这个单元测试挂了,能确保是因为lock方法的逻辑出问题了吗?”

如果我们能不能百分百保证是因为自己的逻辑造成的,就不是一个健壮的单元测试。因为我们的测试结果会随着外部的条件改变而改变,个人觉得这不是单元测试,没有注重单元两个字。

但是有没有办法能够测试自己的行为是否符合预期,而不用关心对方接口的数据以及健康状态,屏蔽对方呢?

其实是有的,我们可以使用Mockito框架进行移花接木。

Mockito简单来说就是通过stub打桩,mock掉我们需要测试的对象,然后通过打桩的方式,插入我们通过方法调用返回的数据,在真实调用此方法的时候,最终会返回我们之前定义的数据。

因为我们项目都是Spring Boot项目,所以以下例子我都使用Mockito支持spring的方法进行mock。

示例

@Primary+Mock

对于spring 项目,我们可以通过以下方式进行mock:

@Configuration
@Profile("MOCK")
public class MockBeanConfig {
    
    @Bean
    @Primary
    public UserFacade getUserFacade() {
        UserFacade userMock = Mockito.mock(UserFacade.class);
        UserInfoDTO userInfoDTO = new UserInfoDTO();
        userInfoDTO.setId(12L);
        userInfoDTO.setName("通过@Primary方式");
        userInfoDTO.setSex(1);
        userInfoDTO.setAge(1);
        when(userMock.getUser(anyLong())).thenReturn(userInfoDTO);
        return userMock;
    }
    
}

@SpringBootTest
@ActiveProfiles("MOCK")
public class MockPrimaryBeanTests {

    @Autowired
    private UserService userService;

    @Test
    public void getUserTest() {
        UserInfoDTO user = userService.getUser(123L);
        Assertions.assertEquals(user.getName(),"通过@Primary方式");
    }

}

通过@Primary注解加自己定义一个Bean,把已经在代码中注入的Bean进行覆盖,然后返回Mock的对象。在Bean中可以进行打桩等处理,然后还能根据Profile区分是否需要进行mock测试,这是一种很不错的方法。spring-boot贡献者们也明白这种方式挺方便的,所以到spring-boot1.4.0版本,提供了一种更方便对Bean进行mock方式-使用@MockBean注解。

@MockBean

我们看看使用@MockBean的例子:

@Slf4j
@SpringBootTest
class MockBeanTests {

    @Autowired
    private ActivityServiceImpl activityOrderService;

    @MockBean
    private ActivityFacade activityFacade;

    @Test
    void simpleTest() {
        ActivityInfoDTO value = new ActivityInfoDTO();
        value.setId(1L);
        value.setName("我是mock的数据");
        when(activityFacade.getActivityInfo(anyLong())).thenReturn(value);
        ActivityInfoDTO activityInfo = activityOrderService.getActivityInfo(1L);
        Assertions.assertEquals(activityInfo.getName(), "我是mock的数据");
    }
}

上面是一个简单的mock例子。上面例子意思是我mock了ActivityFacade这个Bean,然后我们定义了一个活动信息ActivityInfoDTO,通过when(activityFacade.getActivityInfo(anyLong())).thenReturn(value)ActivityFacade这个Bean进行打桩,当真实运行getActivityInfo()方法的时候,实际返回的活动信息将会是我们定义的活动信息。anyLong()的意思是无论传什么Long类型的参数,最终将会返回我们打桩的数据。Mockito不仅支持Long类型,还支持intString等等,甚至你只要使用any()都可以代表任意值。mockito打桩参数可以参考org.mockito.ArgumentMatchers这个类。

当我们如果不用anyLong(),如果是指定了一个参数,那个还会得到mock出来的数据吗?我们看个例子:

    @Test
    void confirmValueTest() {
        ActivityInfoDTO value = new ActivityInfoDTO();
        value.setId(2L);
        value.setName("我是mock的数据");
        when(activityFacade.getActivityInfo(eq(2L))).thenReturn(value);
        ActivityInfoDTO activityInfo = activityOrderService.getActivityInfo(1L);
        Assertions.assertNull(activityInfo);
    }

这里我们使用eq()方法包着了指定值,在进行mock的时候打桩入参需要使用eq()进行校验和存根。当我们指定了值,如果进行真实调用的时候值如果不是我打桩的存根,那么将不会返回我们打桩定义的活动信息。甚至,如果你使用的是@MockBean注解,因为mock了此对象,所以此对象所有方法如果不打桩返回都是空。所以最后断言判空是成功的。

那这时候会有同学想问了,如果我想mock后没有命中打桩传入的参数也要真实调用接口真实返回,那有办法吗?这时候,我们可以使用mockito提供的另外一种方式spy

Spy

spy这个单词就有间谍,偷窥的意思,它的作用也非常明显,就是我能进行真实调用方法,获得真实返回。

接下来我们看个spy简单的例子:

@Slf4j
@SpringBootTest
public class SpyBeanTests {

    @SpyBean
    @Autowired
    private ActivityServiceImpl activityOrderService;

    @SpyBean
    private ActivityFacade activityFacade;

    @Test
    void simpleTest() {
        ActivityInfoDTO value = new ActivityInfoDTO();
        value.setId(1L);
        value.setName("我是mock的数据");
//        when(activityFacade.getActivityInfo(anyLong())).thenReturn(value);
        doReturn(value).when(activityFacade).getActivityInfo(anyLong());
        ActivityInfoDTO activityInfo = activityOrderService.simpleStart(1L);
        Assertions.assertEquals(activityInfo.getName(), "我是mock的数据");
    }

}
    public ActivityInfoDTO simpleStart(Long activityId) {
        ActivityInfoDTO activityInfo = activityFacade.getActivityInfo(activityId);
        activityFacade.start(activityId);
        return activityInfo;
    }

spy的代码和mock差不多,具体流程行为也一样,找到打桩入口并进行打桩。这里使用了@SpyBean注解,然后当我们打桩后进行真实调用,如果ActivityFacade在真实调用的时候还有另外一个方法被调用了(如上例子start()方法),但是start()方法没有进行打桩处理,这时候会进行真实调用。相反,如果使用了@MockBeanstart()方法也只是空调用,不会真实调用。这就是spymock的区别。大家也可以看到,我这里使用了doReturn().when().xxx而不是像mock一样的when().thenReturn(),其实都可以,但是如果在spy下使用when().thenReturn(),在调用之前会进行一次真实调用,所以如果使用spy则使用doReturn().when().xxx比较好。

Captor

那这时候有同学在问,如果我mock掉接口进行测试并且通过了,但也不能代表真实调用其他服务接口就一定成功啊,比如说是传参不正确导致调用其他服务接口失败,有没有办法可以知道入参啊?

一般同学会在方法调用之前或者调用时第一步加上日志,然后真实调用的时候会随着调用会把参数日志输出。这种方法也可以,不过Mockito提供了更加优雅的方法,我们看以下例子:

    // 注解形式
	@Captor
    ArgumentCaptor<CreateActivityInfoParam> captor;

    @Test
    void createTest() {
        // 参数形式
//        ArgumentCaptor<CreateActivityInfoParam> captor = ArgumentCaptor.forClass(CreateActivityInfoParam.class);
        when(activityFacade.createActivityInfo(captor.capture())).thenReturn(new ActivityInfoDTO());
        CreateActivityInfoParam createActivityInfoParam = new CreateActivityInfoParam();
        createActivityInfoParam.setName("我是传进来的对象");
        ActivityInfoDTO activityInfo = activityOrderService.createActivityInfo(createActivityInfoParam);
        log.info("所有参数信息:{}", captor.getAllValues().toString());
    }

输出结果:

2022-03-16 00:26:42.890  INFO 11232 --- [Pool-1-worker-1] com.example.d4c.mock.MockBeanTests       : 所有参数信息:[CreateActivityInfoParam(name=我是传进来的对象)]

可以看出,我们可以使用@Captor注解并且使用ArgumentCaptor定义一个参数,在打桩入参的时候把此对象传入。当我们真实调用方法之后,再通过ArgumentCaptorgetAllValues()获取到所有的入参。另外一种方式是使用ArgumentCaptor.forClass()方法,相当于自己new了一个对象,之后处理和注解形式一样。上面输出结果,就是我们真实调用的入参。

接下来,我们看另外一种获取输出结果的方式:

    @Test
    void createTest() {
        when(activityFacade.createActivityInfo(any())).thenAnswer(o-> {
            log.info("入参:{}", Arrays.toString(o.getArguments()));
            return mockActivityInfoDTO();
        });

        CreateActivityInfoParam createActivityInfoParam = new CreateActivityInfoParam();
        createActivityInfoParam.setName("我是传进来的对象");
        ActivityInfoDTO activityInfo = activityOrderService.createActivityInfo(createActivityInfoParam);
        Assertions.assertEquals(activityInfo.getName(), "我是被mock的对象");
    }

    private ActivityInfoDTO mockActivityInfoDTO() {
        ActivityInfoDTO activityInfoDTO = new ActivityInfoDTO();
        activityInfoDTO.setId(321L);
        activityInfoDTO.setName("我是被mock的对象");
        activityInfoDTO.setStartTime(LocalDateTime.now());
        activityInfoDTO.setEndTime(LocalDateTime.now());
        return activityInfoDTO;
    }

输出结果:

2022-03-16 23:22:26.893  INFO 7108 --- [Pool-1-worker-1] com.example.d4c.mock.MockBeanTests       : 入参:[CreateActivityInfoParam(name=我是传进来的对象)]

可以看出,输出结果和之前的例子是一样的,现在是在打桩的时候不再用thenReturn()而改用了thenAnswer(),这时候可以从thenAnswer()方法的入参中获取到我们想要的真实调用时候的入参。我们看下org.mockito.internal.stubbing.BaseStubbing#thenReturn(T)源码,可以发现thenReturn()内部其实也是调用了thenAnswer(),所以thenReturn()只是方便我们更加注重自己打桩的数据而不关注我能从打桩口中拿到什么参数。

如果我们只是想输出查看调用的入参,没有对入参做一些校验行为,无论是使用@Captor还是使用thenAnswer()输出真实调用入参还是感觉有点麻烦。其实Mockito针对这种场景提供了更加简单的操作,如下例子:

    @Test
    void createTest() {
        when(activityFacade.createActivityInfo(any())).thenReturn(mockActivityInfoDTO());
        CreateActivityInfoParam createActivityInfoParam = new CreateActivityInfoParam();
        createActivityInfoParam.setName("我是传进来的对象");
        ActivityInfoDTO activityInfo = activityOrderService.createActivityInfo(createActivityInfoParam);
        Assertions.assertEquals(activityInfo.getName(), "我是被mock的对象");
        log.info(mockingDetails(activityFacade).printInvocations());
    }

输出结果:

2022-03-16 23:45:54.690  INFO 15428 --- [Pool-1-worker-1] com.example.d4c.mock.MockBeanTests       : [Mockito] Interactions of: com.example.d4c.facade.ActivityFacade@d722fa
 1. activityFacade bean.createActivityInfo(
    CreateActivityInfoParam(name=我是传进来的对象)
);
  -> at com.example.d4c.service.impl.ActivityServiceImpl.createActivityInfo(ActivityServiceImpl.java:57)
   - stubbed -> at com.example.d4c.mock.MockBeanTests.createTest(MockBeanTests.java:86)

这里我们使用了mockingDetails()获取到了被mock对象的mock详情,并且通过printInvocations()打印出真实调用后的详情数据。由上面输出结果看出,printInvocations()方法告诉我了被mock对象真实调用的时候调用了什么方法,传了什么参数,甚至告诉我们在哪一行进行mock对象方法调用,在哪一行进行打桩,并且我们在idea中还能点击直接跳转,非常好用。

MockedStatic

接下来再说说Mockito再远古时期被人诟病的一个问题,就是Mockito是不支持mock静态方法,看各路博客文章都是建议配合上PowerMock使用,因为它支持静态Mockito不支持。其实在Mockito 3.4.0版本开始,早就开始支持静态方法了,Mockito官方文档有介绍。接下来我们看看Mockito支持静态方法例子:

首先加上maven:

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

使用例子:

@Slf4j
public class StaticMethodTests {

    @Test
    void staticMethodTest() {
        LocalDate mockDate = LocalDate.now().minusDays(20L);
        try (MockedStatic<LocalDate> dateUtilsMock = mockStatic(LocalDate.class)) {
            dateUtilsMock.when(LocalDate::now).thenReturn(mockDate);
            log.info(LocalDate.now().toString());
        }
        log.info(LocalDate.now().toString());
    }
}

输出结果:

21:02:53.326 [ForkJoinPool-1-worker-1] INFO com.example.d4c.mock.StaticMethodTests - 2022-02-25
21:02:53.329 [ForkJoinPool-1-worker-1] INFO com.example.d4c.mock.StaticMethodTests - 2022-03-17

这里我们使用了mockStatic()方法mock了LocalDate的所有静态方法,然后通过输出结果可看出,确实输出了前二十天的时间。虽然Mockito是能支持静态方法,但是使用了mockStatic()对一个类mock后,这个类所有的静态方法都被Mockito接管了,比如例子中try里面的LocalDate都是返回打桩后的数据,除非调用close()方法释放静态模拟。并且只对当前线程有效,在多线程会失效。

看到了上面例子,为什么Mock的对象被真实调用的时候,最终会返回自己通过Mockito.when().thenReturn()的对象?实际上Mock本质上就是运用了动态代理,当对象被Mock的时候,他就被代理了。然后通过Mockito.when().thenReturn()设置其返回值,底层简单来说就是一个Map,key是被代理的对象并带有方法和参数信息,value是我们Mockito.when().thenReturn()打桩确定的返回值。当在真实调用的时候,用代理的对象返回预设的返回值。Mockito底层使用的字节码操作框架是bytebuddy,有兴趣的同学可以了解一下。

JMH

什么是JMH

有时候我们写好了Java程序,想进行一下精准的性能测试,这时候我们会研究如何减少误差,如何才能进行严格的性能测试。如果是这样的话,
我们可以使用官方提供的微基准测试工具JMH

JMH 的全名是 Java Microbenchmark Harness,它是由 Java 虚拟机团队开发的一款用于 Java 微基准测试工具。
基准测试是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。 而JMH是一个用来构建,运行,分析Java或其他运行在JVM之上的语言的 纳秒/微秒/毫秒/宏观 级别基准测试的工具。 而且JMH大大方便我们进行一场严格的性能测试,提供了多种测试模式,多种测试的维度,并且使用简单,添加对应的注解就可以进行测试。

为什么需要JMH测试

如果不用JMH,我们通常会这样测试性能:

long start = System.currentTimeMillis();
measure();
long end = System.currentTimeMillis();
System.out.println(end - start);

但是这样测试,有什么问题?

其实大家也知道,如果没问题,JMH就没意义了。我们在测试的时候会遇到诸多陷阱,但是通过JMH基准测试,能帮我们避免测试陷阱,使得测试更有代表性。

代码示例

平时经常看到文章对jasksongsonfastjson这三款序列化框架做性能对比,现在我们自己使用基准测试来测试下他们的吞吐量。

@Fork(1)
@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 100, time = 1, timeUnit = TimeUnit.MILLISECONDS)
@Threads(1)
@State(Scope.Benchmark)
@Measurement(iterations = 100, time = 1, timeUnit = TimeUnit.MILLISECONDS, batchSize = -1)
public class DeSerializeBenchmark {

    private ObjectMapper objectMapper;

    private Gson gson;

    private String param;

    @SneakyThrows
    @Setup
    public void setUp() {
        param = new ResourceScriptSource(new ClassPathResource("placeOrder.json")).getScriptAsString();
        System.out.println(param);
        objectMapper = new ObjectMapper();
        gson = new Gson();
    }

    @Benchmark
    @SneakyThrows
    public void jackson(Blackhole blackhole) {
        // 预防死码消除
        blackhole.consume(objectMapper.readValue(param, PlaceOrderParam.class));
    }

    @Benchmark
    @SneakyThrows
    public void gson(Blackhole blackhole) {
        blackhole.consume(gson.fromJson(param, PlaceOrderParam.class));
    }

    @Benchmark
    @SneakyThrows
    public void fastjson(Blackhole blackhole) {
        blackhole.consume(JSON.parseObject(param, PlaceOrderParam.class));
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DeSerializeBenchmark.class.getSimpleName())
                .result("result.json")
                .resultFormat(ResultFormatType.JSON)
                .build();
        new Runner(opt).run();
    }

}

结果输出:

Benchmark                       Mode  Cnt      Score      Error  Units
DeSerializeBenchmark.fastjson  thrpt  100  33974.011 ±  886.324  ops/s
DeSerializeBenchmark.gson      thrpt  100  59934.132 ± 3095.464  ops/s
DeSerializeBenchmark.jackson   thrpt  100  66654.608 ± 2348.368  ops/s

注解

@Warmup

示例:

@Warmup(
iterations = 10,
time = 1,
timeUnit = TimeUnit.SECONDS)

这个是进行预热的注解,可以使用在方法或者类上面,进行预热配置。此注解提供了几个配置参数:

  • timeUnit :时间的单位,默认的单位是秒
  • iterations :预热阶段的迭代数
  • time :每次预热的时间
  • 每次预热的时间 :批处理大小,指定了每次操作调用几次方法

上面注解的意思是对代码进行预热,预热迭代10次,每次一秒。

一般来说,基准测试都是针对的比较小的、执行速度相对较快的代码块。这些代码有很大的可能被编译、内联,在编码的时候保持方法的精简。所以我们可以先预热一下,减少因为外部原因造成性能测试不严谨。

@Measurement

示例:

@Measurement(
iterations = 1, 
time = 1, 
timeUnit = TimeUnit.SECONDS)

对代码进行迭代,迭代10次,每次一秒。

@Measurement@Warmup的参数看起来是一样的,但是不同的是,Measurement指的是真正的迭代次数。

虽然我们通过预热处理后,代码能表现出它们的最优状态,但是有时候和时机应用场景还是有些不同的。比方说提供给测试的测试机器性能很高,或者说测试机器的资源利用已经到了极限,都会影响我们的测试结果。所以一般情况下,我们都必须做到在测试的时候给予测试机器足够的资源,保持一个稳定的环境。在分析结果的时候,也要关注下不同实现方式下的性能差异,而不是在测试数据本身。

@BenchmarkMode

示例:

@BenchmarkMode({Mode.Throughput,Mode.AverageTime})

统计吞吐量和平均执行时间两个指标,指定基准测试的类型

基准测试包括以下几种类型

  • Throughput:吞吐量,指的是单位时间内的操作数
  • AverageTime:平均耗时,指的是每次操作的平均时间
  • SampleTime:采样,指的是对每个操作的时间进行采样
  • SingleShotTime:单次触发时间,指的是测量单词操作的时间
  • All:所有的指标都运行一遍

此注解一般需要配合@OutputTimeUnit使用,指定一个时间维度,比方说可以指定毫秒来代表每毫秒的吞吐量等

@OutputTimeUnit

实例:

@OutputTimeUnit(TimeUnit.MILLISECONDS)

统计基准测试的时间单位为毫秒

@Fork

实例:

@Fork(value = 1, jvmArgsAppend = {"-Xmx2048m"})

设置进程数和JVM配置追加

fork的值一般需要设置为1,代表只使用一个进程进行测试。如果设置为2,这代表会启动两个进程进行测试。如果设置为0,则表示在用户的JVM进程上运行。不推荐设置为0,因为每次fork进程可以做到环境完全地隔离,避免外部影响到本次基准测试。按照原理来说,fork进程多一点,得到的测试数据误差会更少,不过进程越多基准测试耗时也多。

@Threads

实例:

@Threads(2)

设置线程数为2

指定基准测试线程数

@Group

实例:

@Group("order")

组标签,可以对方法进行分组归类,如果单个测试中方法比较多,可以用此注解进行分组归类

@State

实例:

@State(Scope.Benchmark)

指定了类中的变量的作用范围

作用范围包括如下:

  • Benchmark:基准状态范围。表示变量的作用范围是在同一个基准测试类中
  • Thread:线程状态范围。表示每个线程都有一份副本
  • Group:和@Group一起使用,共享同一个Group的变量实例

@Setup和@TearDown

和JUnit一样,用于基准测试前后的动作,通常用来做一些全局的配置。

可以设置运行时机:

  • Trial:默认的级别。也就是Benchmark级别。
  • Iteration:每次迭代都会运行。
  • Invocation:每次方法调用都会运行,这个是粒度最细的。

@Param

用于修饰字段,用于测试不同的参数对程序性能影响

@CompilerControl

实例:

@CompilerControl(Mode.INLINE)

基准测试强制使用方法内联

这是编译控制注解,解决影响基准测试的特定方法的编译。比方说方法内联。当我们调用简单的方法(eg:getter/setter)的时候,需要创建栈帧,然后执行方法后再弹出栈帧,恢复原来程序的执行。Java方法中调用的开销也是比较大的。JIT编译会帮我们优化,当方法足够小的时候,把这些纳入到原来方法的调用范围内,减少一次方法调用,速度得到提升。(对Java来说确实需要,毕竟getter/setter方法确实很多)。

以下是控制编译模式:

  • BREAK:插入断点
  • PRINT:打印方法
  • EXCLUDE:禁止JIT(即时编译)优化
  • INLINE:强制方法内联
  • DONT_INLINE:跳过方法内联
  • COMPILE_ONLY:仅仅只是编译这个方法

将结果图形化

我们可以格式化基准测试的结果,也能把结果进行图形化展示。通过图表数据分析,我们能够更加直观观察数据。比如以下代码,就是输出JSON格式的数据。

public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(DeSerializeBenchmark.class.getSimpleName())
            .result("result.json")
            .resultFormat(ResultFormatType.JSON)
            .build();
        new Runner(opt).run();
    }

运行得出的结果将以json格式输出到result.json文件中

基准测试的测试结果支持以下几种格式:

  • TEXT:导出文本文件
  • CSV:导出csv格式文件
  • SCSV:导出scsv等格式的文件
  • JSON:导出成json文件
  • LATEX:导出到latex,一种基于ΤΕΧ的排版系统

一般来说,我们通常导出为JSON文件,然后在jmh-visual-chart上传JSON文件,可以获得直观的统计结果。

如下结果:

deSerializeJMH

参考

0

评论区