天天看点

单元测试实践(Spring-boot+Junbit5+Mockito)

单元测试就像胖人说减肥,每个人都说好,但是很少有人做到。从这么多年的项目经历亲身证明,还真是的。

这次借着项目内实施单元测试的机会,记录实施的过程和一些总结经验。

项目情况

首先是背景,项目是一个较大型的历史二手项目,前任团队移交过来的项目,与多个外部服务有交互,采用的是SpringCloud作为基础微服务的架构,中间件涉及Redis,MySQL,RabbitMQ等等。,团队中讨论期望能够利用单元测试来提高服务质量,减少业务投诉。

单元测试的优点很多,但是我觉得最终的目标就是服务质量,单元测试代码如果最终没有能够提高项目质量,说明过程是有问题或者团队没有真正接纳方法,为了完成任务而写测试,以时间不足为由拖延测试用例的开发。

说到单元测试大家肯定会先想起TDD。TDD(Test Dirven Development,测试驱动开发)是以单元测试来驱动开发的方法论。

  1. 开发一个新功能前,首先编写单元测试用例,用于模拟验收或目标功能情况
  2. 运行单元测试,全部失败,即目标已设定
  3. 编写业务代码,并且使对应的单元测试能够通过,目标达成。
  4. 时刻维护你的单元测试,使其始终可通过运行

一开始就直接实施TDD的可能性是比较小的,需要慢慢培训,渗透思想,在长期的运维与应用中让团队感受到测试带来的益处。慢慢建立测试相关规范和体制。让团队成员爱上单元测试。

单元测试范围

一个项目需要实施单元测试,首先要界定(或者说澄清)单元测试负责的范围。最常见的疑惑就是与外部系统或者其他中间件的关联,单元测试是否要实际的调用其他中间件/外部系统。

在敏捷和DevOps的思想指导下,单元测试首先应当是自动化的,由开发者编写,为了保证代码片段(最小单元)是按照预期设计实现的。我们理解就是说单元测试要保障的是项目(代码片段逻辑)自身按照设计意图正确执行,所以确认了单元测试的范围仅限于单个项目内部,因此要尽量屏蔽所有的外部系统或中间件以及外部系统的依赖。代码的业务逻辑覆盖率80%-90%,其他部分(工具类等覆盖率更高)不做要求。

项目涉及到了一些中间件(Mysql,Redis,MQ等),但是更多涉及到的内部其他支撑系统。用项目内的实际情况我们当前定义的单元测试覆盖的范围就是,单元测试从controller作为入口,尽量覆盖到controller和service所有的方法与逻辑,所有的外部接口调用全部mock,中间件尽量使用内存中间件进行mock。

单元测试基础框架

既然项目是基于SpringCloud,那测试肯定会引入基础的spring-boot-test,底层的测试框架选择是junit5。现在最新的是junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。

目前,在 Java 阵营中主要的 Mock 测试工具很多。项目中选择了Mockito,

模拟数据生成参考了jmockdata(com.github.jsonzou:jmockdata:4.1.2),

单元测试实施

基本框架选型完毕,基本就进入了编码阶段。前期的编码,实际上还是先写抽取核心业务进行小规模的验证。

单元测试基本结构

先看一下头部的几个注解,这些都是Junit5的

// 替换了Junit4中的RunWith和Rule
@ExtendWith(SpringExtension.class)
//提供spring依赖注入
@SpringBootTest 
// 运行单元测试时显示的名称
@DisplayName("Test DebitController")
// 单元测试时基于的配置文件
@TestPropertySource(locations = "classpath:it-bootstrap.yml")
class DebitControllerTest{
    private static RedisServer server = null;

    // 下面三个mock对象是由spring提供的
    @Resource
    MockHttpServletRequest request;

    @Resource
    MockHttpSession session;

    @Resource
    MockHttpServletResponse response;

    // junit4中 @BeforeClass
    @BeforeAll
    static void initAll() throws IOException {
        server = RedisServer.newRedisServer(9379); 
        server.start();
    }

    @BeforeEach
    void init() {
        request.addHeader("token", "XXXXXXXXXXXXXXXXXXXX");
    }


    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
        server.stop();
        server = null;
    }

}
           

在前置方法中设置了Header的值用于模拟身份验证

单元测试的主体方法

我们测试的主要的就是DebitController这个类,这个类下面还有一层service方法。先看一下大概的代码印象。

@Resource
    DebitController debitController;

    @MockBean
    private IOrderClient orderClient;

    @Test
    void getStoreInfoById() {
        MockConfig mockConfig = new MockConfig();
        mockConfig.setEnabledCircle(true);
        mockConfig.sizeRange(2, 5);
        MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class);
        StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig);

        Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO));
        Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO));

        R<StoreInfoBizVO> r = debitController.getStoreInfoById();

        assertEquals(r.getData().getAvailableOrderCount(), merchantOrderQueryVO.getOrderNum());
        assertEquals(r.getData().getId(), storeInfoDTO.getId());
        assertEquals(r.getData().getBranchName(), storeInfoDTO.getBranchName());
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 0})
    void logoutCheck(Integer onlineValue) {
        MockConfig mockConfig = new MockConfig();
        mockConfig.setEnabledCircle(true);
        mockConfig.sizeRange(2, 5);
        MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class);
        StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig);
        storeInfoDTO.setOnline(onlineValue);
        Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO));
        Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO));

        R r = debitController.logoutCheck();

        if (1==onlineValue) {
            assertEquals(ResourceAccessor.getResourceMessage(
                    MerchantbizConstant.USER_LOGOUT_CHECK_ONLINE), r.getMsg());
        } else {
            assertEquals(ResourceAccessor.getResourceMessage(
                    MerchantbizConstant.USER_LOGOUT_CHECK_UNCOMPLETED), r.getMsg());
        }
    }

    @ParameterizedTest
    @CsvSource({"1,Selma,true", "2,Lisa,true", "3,Tim,false"})
    void forTest(int id,String name,boolean t) {
        log.info("id={} name={} t={}"+",id,name,t);
        debitController.forTest(null);
    }
           

首先看变量的部分,这里给了两个例子,一个注解是@Resource,这个是让spring来注入的。另外一个是@MockBean,这就是Mockito提供的,并且结合下面的Mockito.when方法。

接下来看方法体,我将方法主体分为三部分:

  1. Mock数据与方法

    使用Mock拦截底层的外部接口方法,并且返回随机的Mock数据(大部分数据可以使用DataMocker生成,有一些特殊有限制的,可以手动生成)。

  2. 测试方法执行

    执行目标测试方法(基本都是一行,直接调用目标方法并且返回结果)

  3. 结果断言

    根据业务逻辑预期进行断言的编写(这部分基本上没有自动化的方式,因为断言的条件和业务逻辑相关只能手动编写)

这样写下来是基本逻辑的验证,还有内部有分支逻辑,如何验证?

代码当中实际上也提到了,就是junit5提供的@ParameterizedTest注解,配合@ValueSource, @CsvSource来使用,分别可以设置指定类型或者复杂类型到单元测试中,使用方法的参数接受,定义测试不同的分支。

单元测试的执行

单元测试的执行实际上分成2部分:

  1. IDE中我们要去验证单元测试是否能够成功执行
  2. CI/CD作为执行的先决条件保障

IDE可以直接指定测试框架,我们选择junit5直接生成单元测试代码,可以直接在测试包或者类上右键执行单元测试。这个方法可以作为我们开发过程中验证待遇测试有效性的手段。但是真正要能在生产开发流程中更好的体现单元测试的价值,还是需要持续集成的支持,我们项目使用的是jenkins。依赖是Maven,以及maven-surefire-plugin插件。要特别注意一点,由于junit5还比较新,所以maven-surefire-plugin插件支持junit5还是稍微有点特殊的,参考官网说明。我们需要引入插件:

<plugin>
					<groupId>org.apache.maven.plugins</groupId>
					<artifactId>maven-surefire-plugin</artifactId>
					<version>3.0.0-M5</version>
					<configuration>
						<argLine> -XX:-UseSplitVerifier</argLine>
						<excludes>
							<exclude>**/*IT.java</exclude>
							<exclude>**/*IntegrationTest.java</exclude>
						</excludes>
					</configuration>
				</plugin>
           

这样在jenkins构建时就会执行单元测试,如果单元测试失败,不会触发构建后操作(Post Steps)。

继续阅读