单元测试及测试覆盖率报告生成

一般在开发中我们建议对于新写的业务逻辑进行单元测试,而不是将所有代码都写完之后再进行测试,这样既不能保证代码的可用性而且后续测试也会比较困难,因此掌握基本的测试API对于开发人员来说也是非常重要的,下面将简单的讲解一下我们开发中可能会用到的一些测试方面的知识。

junit5驱动和断言

和Junit4相比,Junit5框架更多在向测试平台演进。其核心组成也从以前的一个Junit的jar包更换成由多个模块组成。本文所需要依赖模块如下:

  • junit-jupiter-engine: Junit的核心测试引擎
  • junit-jupiter-params: 编写参数化测试所需要的依赖包
  • junit-platform-launcher: 从IDE(InteliJ/Eclipses)等运行时所需要的启动器

依赖引入

<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.6.2</version>
    <scope>test</scope>
</dependency>

驱动&断言

对被测对象的执行过程称为驱动driver,而验证结果是否正确则称之为断言。可以简单理解为执行被测对象是驱动,验证结果是断言。

以下为常见的单元测试注解:

方法名描述
@BeforeAll只执行一次,在所有测试和@BeforeEach注解方法之前执行
@BeforeEach在每个测试执行之前执行
@AfterEach在每个测试执行之后执行
@AfterAll只执行一次,在所有测试和@AfterEach注解方法之后执行

注意:在测试执行前后会进行一些初始化和销毁的操作,因为框架会为每个测试创建一个单独的实例,所以在**@BeforeAll/@AfterAll方法执行时尚无任何测试实例诞生。因此,这两个方法必须定义为静态方法**。

数据驱动

使用@ParameterizedTest注解来实现数据驱动测试,通过一个用例的多组数据从而快速实现

/**
 * 数据驱动使用
 * @author lilei
 */
public class DataDriverTest {
    /**
     * 基础数据数据结构驱动
     *
     * @param words 单词
     */
    @ParameterizedTest
    @ValueSource(strings = {"Radar", "Rotor", "Tenet", "Madam", "Racecar"})
    void multiNameTest(String words) {
        System.out.println(words);
    }

    /**
     * CSV数据结构驱动
     *
     * @param startDate 开始日期
     * @param endDate   结束日期
     */
    @ParameterizedTest
    @CsvSource(
            {"2017-06-01,2018-10-15",
                    "2017-05-01,2018-10-15",
                    "2017-06-01,2018-11-16"}
    )
    void shouldCreateValidDateRange(LocalDate startDate, LocalDate endDate) {
        System.out.println(startDate + "|" + endDate);
    }

    /**
     * 读取csv文件实现数据驱动
     * @param country 国家
     * @param reference 关联行数
     */
    @ParameterizedTest
    @CsvFileSource(resources = "/country.csv",numLinesToSkip = 1)
    void CsvFileSourceTest(String country,String reference) {
        System.out.println(country+"-"+reference);
    }

    /**
     * 方法数据源
     * @param fruitName 水果
     * @param num 数字
     * @param list 集合
     */
    @ParameterizedTest
    @MethodSource("stringIntAndListProvider")
    void testWithMultiArgMethodSource(String fruitName, int num, List<String> list) {
        assertEquals(5, fruitName.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"))
        );
    }
}

image.png

基于Spring的单元测试

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({"classpath:config-init.xml"})
public class SpringTest {

    @Autowired
    MsgSendLogMapper msgSendLogMapper;

    @Test
    public void springConfigTest() {
        // do something....
    }
}

基于SpringtBoot的单元测试

Service测试

 @SpringBootTest
 public class UserServiceImplTest {
        @Resource
        private UserService userService;

        @Test
        @Transactional
        @Rollback(false) //是否进行回滚
        void test1()
        {
            User myu=new User();
            myu.setId(13);
            myu.setUsername("red13");
            myu.setPassword("passw0ld");
            myu.setDemo("testjunit");
            this.userService.insert(myu);
            Assertions.assertEquals(this.userService.queryById(13).getId().toString(),"13");
            // this.userService.deleteById(12);
        }

Controller测试

@ExtendWith(SpringExtension.class)
@WebMvcTest(DouyinContractController.class)
class DouyinContractControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private IDouYinContractService mockContractService;

    @Test
    void testBindCard() throws Exception {
        // Setup
        // Configure IDouYinContractService.preContract(...).
        final DouyinPreContractRes preContractRes = new DouyinPreContractRes();
        preContractRes.setPreEntrustwebId("preEntrustwebId");
        preContractRes.setCode("code");
        preContractRes.setMessage("message");
        final PreContractRes res = new PreContractRes(preContractRes);
        final PreContractReq preContractReq = new PreContractReq();
        final ModelsUserInfo userInfo = new ModelsUserInfo();
        userInfo.setUserName("userName");
        userInfo.setIdCardNum("idCardNum");
        userInfo.setCardType("cardType");
        preContractReq.setUserInfo(userInfo);
        preContractReq.setContractId("contractId");
        when(mockContractService.preContract(preContractReq)).thenReturn(res);

        // Run the test
        final MockHttpServletResponse response = mockMvc.perform(post("/douyinPay/contract/preContract")
                        .content(JsonUtils.toJSONStringNotNull(preContractReq)).contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andReturn().getResponse();

        // Verify the results
        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        assertThat(response.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("{\"resCode\":\"9999\",\"resMessage\":\"服务异常\",\"resData\":null}");
    }

基于Dubbo接口的单元测试

使用dubbo泛化调用,具体讲解后续说明

public class DubboServiceTest {
    private static final String ZK_ADDR = "zookeeper://172.17.10.157:2181";
    private static final String APP_NAME = "test";
    private static final String VERSION = "1.0.0";
  
 
    @Test
    public  void dubboTest() {
        Class interfaceName = MsgServiceMultiInterface.class;
        //获取指定类型的dubbo接口
        MsgServiceMultiInterface context = getInterfaceByClassType(interfaceName);

        ModelsMessageRequest modelsMessageRequest = new ModelsMessageRequest();

        ModelsReturn modelsReturn = context.message(modelsMessageRequest);
  
    }

    private static MsgServiceMultiInterface getInterfaceByClassType(Class interfaceName) {
        ApplicationConfig application = new ApplicationConfig();
        application.setName(APP_NAME);
        // 连接注册中心配置
        RegistryConfig registry = new RegistryConfig();
        registry.setAddress(ZK_ADDR);
        // 注意:ReferenceConfig为重对象,内部封装了与注册中心的连接,以及与服务提供方的连接
        // 引用远程服务
        ReferenceConfig<MsgServiceMultiInterface> reference = new ReferenceConfig<MsgServiceMultiInterface>(); // 此实例很重,封装了与注册中心的连接以及与提供者的连接,请自行缓存,否则可能造成内存和连接泄漏
        reference.setApplication(application);
        reference.setRegistry(registry); // 多个注册中心可以用setRegistries()
        reference.setInterface(interfaceName);
        reference.setVersion(VERSION);
        MsgServiceMultiInterface context = reference.get();
        return context;
    }


	public interface MsgServiceMultiInterface {
    	ModelsReturn message(ModelsMessageRequest var1);
	}

mockito

mock最大的功能是帮你把单元测试的耦合分解开,如果你的代码有一个类、接口的依赖,它能够帮你模拟这些依赖并帮你验证所调用的依赖的一些行为。

假设代码中有如下图所示的依赖:

image.png

当我们需要测试A类的时候,如果没有mock那么我们需要将整个依赖构建出来,而mock则可以将结构分解开,如下图所示:

image.png

使用范畴

  • 真实对象具有不可确定的行为,不可预测
  • 真实对象不好创建
  • 真是对象的某些行为很难触发
  • 真实对象实际上还不存在(比如该接口是三方或者其他开发小组的某个接口)

基本API介绍

方法名描述
mock()/@MockMock是指使用Mockito创建的模拟对象,它模拟真实对象的行为,用于替代真实对象的依赖项,以便进行独立的单元测试。
@InjectMocks@InjectMocks是一个Mockito注解,用于自动将模拟对象注入到被测对象中的相应字段中
doReturn()doReturn()方法用于为模拟对象设置方法调用的返回值,可以覆盖默认行为。
when()when()方法用于指定模拟对象的方法调用,并设置相应的操作,例如返回值、异常等。
verify()verify()方法用于验证模拟对象的方法是否被调用,并可以进一步验证方法的调用次数和参数。

这里就不一一叙述了,可以参照:

API介绍:https://www.jianshu.com/p/54a28f6adf70

结合项目使用:https://zhuanlan.zhihu.com/p/640890853

squareTest插件使用

squareTest是一款自动生成单元测试的插件,覆盖率较广,比较推荐使用,可以减少测试工作量。

前置检查

  • 引入mockito相应依赖

        <dependency>
          <groupId>org.mockito</groupId>
          <artifactId>mockito-core</artifactId>
          <version>2.23.4</version>
          <scope>test</scope>
        </dependency>
    
  • 检查是否有测试目录,若没有则添加,否则可能生成的时候出现如下弹窗

    image.png

使用介绍

异常情况解决
  • 若在生成单元测试代码时出现如下图弹框,需自定义设定测试路径

    image.png

    image.png

  • 若代码运行单元测试时还有verify、或assert相关的错误找到对应错误位置直接删除即可,也可以验证的时候替换为Assert.assertNotNull(返回结果),最终是不会影响代码覆盖率的

    image.png

  • 生成代码是对于Controller层的代码无法精确到业务,需自行调整

    image.png

    找到对应的Controller调用对应的service进行生成,所以对于Controller层的建议分别对service和Controller进行测试代码生成

  • 对于没有覆盖测试到的地方,需要根据判断的字段进行针对性的赋值

jacoco测试覆盖插件使用

依赖引入

 <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M5</version>
                <configuration>
                    <testFailureIgnore>true</testFailureIgnore>
                    <includes>
                        <!--包含Test后缀的文件-->
                        <include>*/*Test.java</include>
                    </includes>
                    <excludes>
                        <!--<exclude>*/*Test.java</exclude>-->
                    </excludes>
                    <!-- 解决Jacoco错误,错误信息: Skipping JaCoCo execution... -->
                    <!--<argLine>${argLine} -Xmx2048m</argLine>-->
                </configuration>
            </plugin>


            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.7</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>test</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                        <configuration>
                            <!--定义输出的文件夹-->
                            <outputDirectory>${project.build.directory}/jacoco-report</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

使用介绍

导入依赖后使用maven test,插件会在target生成对应index.html,打开即可看到对应测试情况

注意:

单元测试的结构目录必须与源码的工程目录相同!!!否则会出现检测不到对应测试类

image.png

image.png

image.png

On the fly动态覆盖率模式(待完善)

Idea coverage使用

对于coverage没有达到预期效果,则可以idea自带代码覆盖工具,该工具可以配置第三方(例如:jacoco)并可以以html形式报告呈现,下面讲解下Idea自带覆盖率扫描工具的使用。

  • 基于包级别的coverage

    1. 找到需要测试的单元测试类

      image.png

    2. 运行后右侧会有coverage边框,会扫描测试类对应包的所有类,并显示对应的覆盖率

      image.png
      image.png

    3. 通过代码覆盖情况可修改对应参数,提高覆盖率(红色-未覆盖到 ,绿色-已覆盖)

      image.png

    4. 也可以通过直接找到项目对应的包进行run converage

      image.png

EasyRandom使用

在测试类中构造对象时使代码比较冗余,使用EasyRandom可高效构造对象

依赖引入

<dependency>
    <groupId>org.jeasy</groupId>
    <artifactId>easy-random-core</artifactId>
    <version>4.3.0</version>
    <exclusions>
    	<!-- 跟 SpringAOP 引入的 objenesis 有冲突,要排除 -->
        <exclusion>
            <groupId>org.objenesis</groupId>
            <artifactId>objenesis</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- 支持根据参数校验逻辑生成对象字段 -->
<dependency>
    <groupId>org.jeasy</groupId>
    <artifactId>easy-random-bean-validation</artifactId>
    <version>4.3.0</version>
</dependency>

封装工具类

import org.jeasy.random.EasyRandom;
import org.jeasy.random.EasyRandomParameters;
import org.jeasy.random.api.Randomizer;
import org.jeasy.random.api.RandomizerRegistry;

import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import java.util.stream.*;

import static org.apache.commons.lang3.RandomUtils.nextDouble;

/**
 * 随机工具,封装 EasyRandom 提供的对象和 List 的随机生成方法
 *
 * @author lilei
 */
public class RandomUtil {
    private static final EasyRandom easyRandom;

    static {
        EasyRandomParameters param = new EasyRandomParameters();
        param.setStringLengthRange(new EasyRandomParameters.Range<>(5, 10));
        // 注册自定义随机生成器
        param.randomizerRegistry(new BigDecimalRegistry());
        // 生成的对象是一个是接口或者抽象类,则扫描类路径找到它的一个具体实现类
        param.setScanClasspathForConcreteTypes(true);
        param.setObjectPoolSize(100);
        param.setRandomizationDepth(12);
        easyRandom = new EasyRandom(param);
    }

    private RandomUtil() {
        throw new UnsupportedOperationException("静态工具类不允许被实例化");
    }

    /**
     * 根据给定的类型生成一个随机的对象
     */
    public static <T> T nextObject(Class<T> clz) {
        return easyRandom.nextObject(clz);
    }

    /**
     * 根据给定的类型和大小生成一个随机对象的列表
     */
    public static <T> List<T> nextList(Class<T> clz, int size) {
        return objects(clz, size).collect(Collectors.toList());
    }

    /**
     * 根据给定的类型和大小生成一个随机对象的集合
     */
    public static <T> Set<T> nextSet(Class<T> clz, int size) {
        return objects(clz, size).collect(Collectors.toSet());
    }

    /**
     * 根据给定的类型和大小生成一个随机对象流
     */
    public static <T> Stream<T> objects(Class<T> clz, int size) {
        return easyRandom.objects(clz, size);
    }

    /**
     * 默认生成的 BigDecimal 实例精度过大,将会导致用于插入数据库时超过精度而报错,所以我们默认精度取为5
     */
    private static class BigDecimalRegistry implements RandomizerRegistry {
        static final BigDecimalRandomizer bigDecimalRandomizer = new BigDecimalRandomizer();

        @Override
        public void init(EasyRandomParameters easyRandomParameters) {
        }

        @Override
        public Randomizer<?> getRandomizer(Field field) {
            if (field.getType() == BigDecimal.class) {
                return bigDecimalRandomizer;
            } else {
                return null;
            }
        }

        @Override
        public Randomizer<?> getRandomizer(Class<?> aClass) {
            if (aClass == BigDecimal.class) {
                return bigDecimalRandomizer;
            } else {
                return null;
            }
        }
    }

    private static class BigDecimalRandomizer implements Randomizer<BigDecimal> {
        @Override
        public BigDecimal getRandomValue() {
            return BigDecimal.valueOf(nextDouble(Integer.MIN_VALUE, Integer.MAX_VALUE))
                    .setScale(5, BigDecimal.ROUND_HALF_UP);
        }
    }
}

使用方式

// 注意:对于有泛型的对象无法直接进行初始化,需单独进行初始化,内部的对象再使用randomUtil
// 随机生成一个 user 对象
User user = RandomUtil.nextObjet(User.class);
// 随机生成一个 user 对象列表
List<User> users = RandomUtil.nextList(User.class, 10);

更多推荐