面向实际的单测完整解决方案分享

1,848 阅读16分钟

前言

本文整理自前不久在组内组织的一次单元测试分享。

背景主要是后续我们的持续集成流程中会增加单测覆盖率这个一个卡点,大家之后需要慢慢将手头上的服务的单测补充起来。然后就发现组里的人对单测这个事情的理解有很大的偏差,并且有些人不知道怎么去写单测。所以就有了这么一次分享。

本文大纲如下:

  • 为什么要进行单元测试
  • 怎么做单元测试(术与道)
    • 单测之道
    • 单测之术
      • Spock - 一站式的单元测试利器
      • TestableMock - 一款特立独行的轻量Mock工具
  • 一些思想误区
  • 结语

为什么要写单元测试

首先我们要搞清楚写单元测试的目的是什么?

单测的目的:尽早尽量小的范围内暴露错误

作为开发人员,我们都应该知道,越早发现的缺陷,其修复成本是越低的。

另外注意尽量小的范围这个描述,这个意味着一个单测方法的关注点应该尽量最小粒度的,即理想情况下,一个单测方法应该对应功能类的一个方法的一个逻辑分支(logic branch)。

另外,我们再谈谈单元测试的好处,包括但不限于:

  • 提升软件质量

    优质的单元测试可以保障开发质量和程序的鲁棒性。越早发现的缺陷,其修复的成本越低。

  • 促进代码优化

    单元测试的编写者和维护者都是开发工程师,在这个过程当中开发人员会不断去审视自己的代码,从而(潜意识)去优化自己的代码。

  • 提升研发效率

    编写单元测试,表面上是占用了项目研发时间,但是在后续的联调、集成、回归测试阶段,单测覆盖率高的代码缺陷少、问题已修复,有助于提升整体的研发效率。

  • 增加重构自信

    代码的重构一般会涉及较为底层的改动,比如修改底层的数据结构等,上层服务经常会受到影响;在有单元测试的保障下,我们对重构出来的代码会多一份底气。

像我们这次的背景就是强调要提升整个研发团队的研发效能,单测覆盖率作为必不可少的一环,也就加到了我们的持续集成流程中。

单测之道

基本原则和基本要求

这部分的讲述主要是为了强调写单测时的一些注意点,纠正一些同学对单测的错误写法和认知。

首先就是 AIR 原则。在包括阿里开发手册等很多文章和规约中关于单元测试的要求里面都提到了这个原则。

单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。

A:Automatic(自动化)

单元测试应该是全自动执行的,并且非交互式的。测试框架通常是定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。

单测要能报错,单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。

有些同学不喜欢用Assert,而喜欢在test case中写个System.out.println,人肉观察一下结果,确定结果是否正确。这种写法根本不是单测,原因是即使当时被测试代码是正确的,后续这些代码还有可能被修改,而一旦这些代码被改错了。println根本不会报错,测试正常通过只会带来虚假的自信心,这种所谓的"单测"连暴露错误的作用都起不到,根本就不应该存在。

I:Independent(独立性)

保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。

反例:method2需要依赖method1的执行,将执行结果做为method2的输入。。

R:Repeatable(可重复)

单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

除了这上面的 AIR 原则,关于单测还有几点需求:

要有强度

单测的结果校验靠的是最后的assert断言,断言需要反映对应功能分支的明确需求才能有强度。

举个简单的例子,假设有以下的待测方法:

public class FileInfoService {

    public FileInfoDTO singleQuery(Long id) {
        // 实现
    }
}

以下的断言强度就显得较弱,只对文件信息是否为Null做了判断。

(我使用的是spock测试框架,后面会具体说)

    @Issue("单笔查询 - 对应id存在时")
    def "test SingleQuery when_id_exists"() {
        given: "一个存在的文件id"
        Long existId = 1

        when:
        FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)

        then: "判断文件信息是否查询正确"
        fileInfoDTO != null
    }

正确的断言应该到判断具体字段是否正确的强度:

	// 假设测试库中已经插入了 id=1 的文件信息
	@Issue("单笔查询 - 对应id存在时")
  def "test SingleQuery_id_exists"() {
        given: "一个存在的文件id"
        Long existId = 1

        when:
        FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)

        then: "判断文件信息是否查询正确"
    		fileInfoDTO != null
        Objects.equals(fileInfoDTO.fileName, "***.png")
    }

覆盖率保证

强度是指单元测试中对结果的验证要全面,覆盖度则是指测试用例本身的设计要覆盖被测试程序(SUT, Sysem Under Test)尽可能多的逻辑。只有覆盖度和强度都比较高才能较好的实现单测的目的。

按照测试理论,SUT的覆盖度分为方法覆盖度,行覆盖度,分支覆盖度和组合分支覆盖度几类。不同的系统对单测覆盖度的要求不尽相同,但这是有底线的。一般来说,程序配套的 单测至少要达到>80%的方法覆盖以及>60%的行覆盖 ,才能起到"看门狗"的作用,也才是有维护价值的单测。

粒度要小

和集成测试不同,单元测试的粒度一定要小,只有粒度小才能在出错时尽快定位到出错的地点。单测的粒度最大是类,一般是方法。单测不负责检查跨类或者跨系统的交互逻辑 , 那都是集成测试的范围。

通俗的说,程序员写单测的目的是"擦好自己的屁股",把自己的代码从实现中隔离出来,在集成测试前先保证自己的代码没有逻辑问题。至于集成测试乃至其它测试中暴露出来的接口理解不一致或者性能问题,那都不在单元测试的范围内。

速度要快

作为"看门狗",最好是在每次代码有修改时都运行单元测试,这样才能尽快的发现问题。这就要求单元测试的运行一定要快。一般要求 单个测试的运行时间不超过3秒 , 而整个项目的单测时间控制在3分钟之内,这样才能在持续集成中尽快暴露问题。

上面只是一般性要求,具体情况当然需要具体分析对待

单测不仅仅是给持续集成跑的,跑测试有时更多的是程序员本身, 单测速度和程序员跑单测的意愿成反比 ,如果单测只要5秒,程序员会经常跑单测,去享受一下全绿灯的满足感,可如果单测要跑5分钟,能在提交前跑一下单测就不错了。

比如上面这种全绿灯的“快感”。

单测之术

前面讲了这么多的理论,可能各位看官们都看厌了,下面我们以 Spring boot 开发为例,就讲一些具体的技术方案。

测试框架 - Spock

首先是单测的技术框架选型。我这里选用的是spock,而不是大家常用的Junit。

我这边就拿 Junit5 和 Spock 对比两个例子。其他的可以看附录中的扩展资料。

可读性和维护性方面

spock 的单测结构是基于一种given-when-then的句式结构,这种概念来源于BDD。简而言之,它统一了测试的创建,提高了测试的可读性,并使编写起来更容易,尤其是对于经验不足的人。

比如这是一段用spock写的单测代码:

class SimpleCalculatorSpec extends Specification {
    def "should add two numbers"() {
        given: "create a calculater instance"
            Calculator calculator = new Calculator()
        when: "get calculating result via the calculater"
            int result = calculator.add(1, 2)
        then: "assert the result is right"
            result == 3
    }
}

用 junit 写是什么样的:

class SimpleCalculatorTest {
    @Test
    void shouldAddTwoNumbers() {
        //given
        Calculator calculator = new Calculator();
        //when
        int result = calculator.add(1, 2);
        //then
        assertEquals(3, result);
    }
}

在junit中,你只能用注释表达你的意图。

写的快

其实对于大多数人而言(包括我),写单测都是一件相对痛苦的事情,因为单测的代码量绝对不会比你对应功能类的代码量要少。写单测已经如此痛苦了,为什么不能让这件事情稍稍变得舒服一点?

比如异常断言,我们为了断言的强度,我们有时不止要判断是否抛出对应异常还要判断异常的属性。这是junit5的写法:

@Test
void shouldThrowBusinessExceptionOnCommunicationProblem() {
    //when
    Executable e = () -> client.sendPing(TEST_REQUEST_ID)
    //then
    CommunicationException thrown = assertThrows(CommunicationException.class, e);
    assertEquals("Communication problem when sending request with id: " + TEST_REQUEST_ID, thrown.getMessage());
    assertEquals(TEST_REQUEST_ID, thrown.getRequestId());
}

这是spock的写法:

def "should capture exception"() {
    when:
        client.sendPing(TEST_REQUEST_ID)
    then:
        def e = thrown(CommunicationException)
        e.message == "Communication problem when sending request with id: $TEST_REQUEST_ID"
        e.requestId == TEST_REQUEST_ID
}

还有就是我们最常用的mock,Junit5 中内置了mockito 这个mock工具。

@Test
public void should_not_call_remote_service_if_found_in_cache() {
    //given
    given(cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER)).willReturn(Optional.of(PLUS));
    //when
    service.checkOperator(CACHED_MOBILE_NUMBER);
    //then
    then(webserviceMock).should(never()).checkOperator(CACHED_MOBILE_NUMBER);
//   verify(webserviceMock, never()).checkOperator(CACHED_MOBILE_NUMBER);   //alternative syntax
}

spock框架内置了一个mock子系统,提供mock相关的功能:

def "should not hit remote service if found in cache"() {
    given:
        cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER) >> Optional.of(PLUS)
    when:
        service.checkOperator(CACHED_MOBILE_NUMBER)
    then:
        0 * webserviceMock.checkOperator(CACHED_MOBILE_NUMBER)
}

可以看到,虽然 mockito的语法非常可读(given/thenReturn/thenAnswer/...),但是你不会觉得,我只是想mock一下这个方法的调用,为什么要写如此多的代码?

关于spock 和 junit5 的进一步对比,大家可以阅读这篇文章spock-vs-junit-5,上述部分的对比代码也节选于此;

另外关于spock的使用,具体可以参照我写的一篇文章:Spock in Java 慢慢爱上写单元测试;当然官方文档是更好的选择:

mock 工具 - TestableMock

到这里可能会有人说了,spock不是内置mock工具吗,为啥还要单独再说mock工具的事情。主要spock mock和 mockito 一样,并不全面。

比如我想mock静态方法,spock就做不到了。在mock全能方面,第一个想到的应该是 powermock,但是呢,powermock 用在 spock 上面有兼容性的问题,我google了很多资料但是都没有解决。并且引入的依赖项都十分复杂,所以我就放弃了powermock。转向了 TestableMock 这款阿里开源的mock工具。

官方并没有说明这个框架可以和spock结合使用,但是按照其mock的原理和实际测试下来,是可以在spock中使用的。

我的场景是我的待测方法中引入了 javax.imageio.ImageIO 这个类,我并不想实际调用的它的read(InputStream input) 方法,因为我没有真实的图片输入流。所以我要把它mock掉。

step1 引入依赖

<dependency>
    <groupId>com.alibaba.testable</groupId>
    <artifactId>testable-all</artifactId>
    <version>${testable.version}</version>
    <scope>test</scope>
</dependency>

step 2 声明mock容器

mock 容器算是 TestableMock 里面的概念。在对应测试类中再申明一个静态类

    static class Mock {

        // 放置Mock方法的地方
        @MockMethod(targetClass = ImageIO.class)
        private static BufferedImage read(InputStream input) throws IOException {
          // 借用MOCK_CONTEXT来判断不同的测试case
            switch ((String) MOCK_CONTEXT.get("case")) {
                case "normal":
                    return new BufferedImage(100, 200, 1)
                case "error":
                    throw new IOException()
                default:
                    return null
            }
        }

    }

MOCK_CONTEXT 主要是为了不同的输出场景。

step 3 编写对应测试case的代码

    def "test createFileInfo pic normal"() {

        when:
        //... logic
          
        // 确定mock的case
        MOCK_CONTEXT.put("case", "normal");

        FileInfo fileInfo = fileService.createFileInfo(********)
      
        then:
        fileInfo.width == 100
        fileInfo.height == 200
    }

数据层(DAO)层测试

DAO层测试,是我在分享的时候唯一建议同学们启动 spring 容器测试的情况。

我们集团内部使用的是 MyBatis Plus,在实际场景中,DAO对数据库的操作依赖于mybatis的sql mapper 文件或者基于MyBatis Plus的动态sql,在单测中验证所有sql 逻辑的正确性非常重要,在DAO层有足够的覆盖度和强度后,Service层的单测才能仅仅关注自身的业务逻辑。

为了验证,我们需要一个能实际运行的数据库。为了提高速度和减少依赖,可以使用内存数据库。下面的方案中就使用 H2 作为单测数据库。

step1 引入依赖

        <!-- h2database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.197</version>
            <scope>test</scope>
        </dependency>

				
				<!-- spock相关 -->
        <!-- https://mvnrepository.com/artifact/org.spockframework/spock-core -->
        <dependency>
            <groupId>org.spockframework</groupId>
            <artifactId>spock-core</artifactId>
            <version>1.1-groovy-2.4</version>
            <scope>test</scope>
        </dependency>

				<!-- 在spock中集成spring容器测试 -->
        <dependency>
            <groupId>org.spockframework</groupId>
            <artifactId>spock-spring</artifactId>
            <version>1.1-groovy-2.4</version>
            <scope>test</scope>
        </dependency>

        <!-- enables mocking of classes without default constructor -->
        <dependency>
            <groupId>org.objenesis</groupId>
            <artifactId>objenesis</artifactId>
            <version>2.6</version>
            <scope>test</scope>
        </dependency>

step2 准备初始化sql

在测试资源目录 src/test/resource 下新建 db/{your_module}.sql ,其中的内容是需要初始化的建表语句,也可以包括部分初始记录的dml语句,至于怎么导,很多数据库工具可以实现。如果表结构发生了更改,需要人工重新导出。

step3 准备测试类

@Title("Dao测试")
@ContextConfiguration(classes = [DaoTestConfiguration.class])
class FileInfoDaoTest extends Specification {
  	@Autowired
    private FileInfoService fileInfoService
}

@ContextConfiguration(classes = [DaoTestConfiguration.class])这个注解很关键,他是spring-test模块的注解,通过这个注解可以配置spring容器在启动时的上下文。如果你不指定,那一般来说就默认是你Spring Boot 的 Application 类决定的上下文。

再来看看DaoTestConfiguration.class里面做了什么配置:

@Configuration
@ComponentScan(basePackages = {"com.***.***.dao"})
@MapperScan({"com.****.***.mapper"})
@SpringBootApplication(exclude = {FeignAutoConfiguration.class, ApolloAutoConfiguration.class,***.class,***.class,
        Swagger2AutoConfiguration.class, FeignRibbonClientAutoConfiguration.class,
        ManagementContextAutoConfiguration.class, JmxAutoConfiguration.class,
        BeansEndpointAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
        TaskExecutionAutoConfiguration.class, AuditAutoConfiguration.class,
        LoadBalancerAutoConfiguration.class, RefreshEndpointAutoConfiguration.class, HystrixAutoConfiguration.class,
        RibbonAutoConfiguration.class, ConsulDiscoveryClientConfiguration.class})
public class DaoTestConfiguration {

    @Bean
    public DataSource dataSource() {
        EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
        return builder.setType(EmbeddedDatabaseType.H2).addScript("classpath:db/***.sql").build();
    }

}

这个类里面关键点主要有几个:

  • @SpringBootApplication(exclude = {})

    通过这个注解,把一些自动装配的类给排除掉了。比如我的项目集成了Apollo,swagger等组件,还有我们的微服务框架是在 spring cloud 基础上自研的,项目里面也做了很多自动装配类的处理,这些在单元测试的环节都可以说是无用的,增加单测的启动时间不说,甚至会让你的单测启都起不起来。

    还记得我们上面的原则吗,单测要尽可能得对外部环境没有依赖。所以,去掉这些自动装配类是很必要的。当然,这一块需要具体项目具体分析。因为不同项目引入的组件不同,另外还要小心别把spring boot自身启动需要的自动装配类给排了,也会导致起不来。

  • @ComponentScan(basePackages = {"com.***.***.dao"})

    因为我们只测Dao层,所以只需要扫描Dao层的类即可。

  • 配置 h2 数据源,指定初始化sql脚本

剩下的工作就是编写具体的测试代码即可。

    def "UpdateFileSize"() {
        given:
        FileInfo fileInfo = fileInfoDao.getById(1)
        def size = fileInfo.fileSize + 1

        when:
        fileInfoDao.updateFileSize(fileInfo.id, size)

        then: "size更新成功"
        fileInfoDao.getById(1).fileSize == size
    }

其他类的测试

对于除数据库以外的依赖,包括消息中间件、缓存等中间件以及外部的微服务、第三方API,在单测中全部采用Mock进行解耦。

我想了想,还是在这演示下一个基于多个服务依赖的功能类应该怎么测试。

假设有这个一个类,他的功能实现依赖于3个外部服务(你可以想象成一个feign client、dubbo provider、thrid api服务)

/**
 * 根据黑名单和白名单确认用户是否能访问
 */
@Service
public class AccessService {

    @Autowired
    private UserService userService;

    @Autowired
    private BlackListProvider blackListProvider;

    @Autowired
    private WhiteListProvider whiteListProvider;


    /**
     * 返回指定用户是否能够访问
     * @param userId 用户ID
     *
     * @return
     */
    public boolean canAccess(Long userId) {

        List<Long> whiteListProviderList = whiteListProvider.provideUserIdWhiteList();
        if (whiteListProviderList.contains(userId)) {
            return true;
        } else {
            String name = userService.findNameById(userId);
            return !blackListProvider.provideUserNameBlackList().contains(name);
        }
    }
}

编写对应测试类如下:

@Subject(AccessService)
class AccessServiceTest extends Specification {

    def "test canAccess"() {

        given: "准备数据和mock"
        // mock 外部服务
        UserService userService = Mock()
        WhiteListProvider whiteListProvider = Mock()
        BlackListProvider blackListProvider = Mock()

        // 初始化
        AccessService accessService = new AccessService()
        // 赋值
        accessService.userService = userService;
        accessService.whiteListProvider = whiteListProvider;
        accessService.blackListProvider = blackListProvider;
        // 有人可能会奇怪,这三个服务不是都是私有变量吗,可以这么赋值吗?
        // 对,groovy中就是可以这么方便!

        and: "打桩"
        // 打桩是mock中的另一个名词,意思就是指定对应输入时返回对应的输出
        // user服务传入1L/2L/3L/4L,返回tom/jerry/anna/lucky
        userService.findNameById(1L) >> "tom"
        userService.findNameById(2L) >> "jerry"
        userService.findNameById(3L) >> "anna"
        userService.findNameById(4L) >> "lucky"

        // 白名单包含jerry,anna和lucky
        whiteListProvider.provideUserIdWhiteList() >> [2L, 3L, 4L]

        // 黑名单包含 "tom","jerry","peter"
        blackListProvider.provideUserNameBlackList() >> ["tom", "jerry", "peter"]


        expect:
        result == accessService.canAccess(userId)

        where:
        userId | result
        // 在黑名单里,不在白名单里,不能访问
        1L | false
        // 在黑名单里,也在白名单里,可以访问
        2L | true
        // 不在黑名单里,在白名单里,可以访问
        3L | true
        // 不在黑名单里,也不在白名单里,可以访问
        4L | true
    }

}

这里不展开讲spock的语法。

一些思想误区

补单测

补单测是很有责任心的表现,但还是要说 单测应该随着代码同时产生,而不应该是补出来的

当一段代码(一个类或者一个方法)刚被写出来的时候,开发对整个上下文非常清楚,要测试什么逻辑也很明确(单测是白盒测试),这时候写单测速度最快,也最容易设计出高强度的单元测试。如果等一次产出N个类,上千行代码再去写单测,很多当时的上下文都已经遗忘了,而且惰性会使人面对大量工作时产生畏难情绪,这时写的单测质量就比较差了。至于为几个月甚至几年前的代码写单测,基本上除了大规模重构,是没人愿意去写的。

在测试前置这方面最激进的尝试是TDD (Test Driven Development),其次是TFD (Test First Development),它们都要求单测在代码前完成。尽管这两个实践目前不是很流行,但还是推荐有兴趣的同学去尝试一下TDD,经过TDD熏陶的代码会自然的觉得单元测试是程序的一部分,对于这点理解也会更深。

项目紧,没时间写单测

这也是没有写单测习惯的开发经常会说的话。

再紧的项目都要有设计、编码、测试和发布这些环节,如果说项目紧不写单测,看起来编码阶段省了一些时间,但如果存在问题,必然会在测试和线上花掉成倍甚至更多的成本来修复。

也就是说,可能从开发的角度,你的工作是因为不写单测按时完成了,但是从整体项目/功能的交付时间来看,却不是这样。所以如果你的团队是个懂得单测重要性的团队,就应该在评估开发时间的把单测的时间考虑进去。

错误率是恒定的,需要的调试量也是固定的,用测试甚至线上环境调试并不能降低调试的量,只会降低调试效率。

单测是QA的工作

这是混淆了单元测试和集成测试的边界。

单元测试是白盒测试,应该随着代码一起产出,一起修改。单元测试的目的是让程序员"擦干净自己的屁股",保证相对小的模块确实在按照设计目标工作。单元测试需要代码和程序同时变动,不要说QA,就是换个开发写单测都赶不上这个节奏(除非结对编程)。所以单元测试一定是开发的工作。

集成测试是黑盒测试,一般是端到端的测试,很大的工作量在维护上下游环境的兼容上。集成测试运行的频率也比单元测试低,这部分工作由QA来作还是可以接受的。

结语

本文讲述了单测的术和道,介绍了单测的一些道理和具体的技术方案。文中提到的spock并非是强硬性要求,只是我个人偏好而已。其实用junit写也是能写的,只是代码量会比较多。看你哪个用的熟。

我个人觉得关键的是在于树立起对单测的正确认知,在实际操作中做到文中提到的几个原则和要求。越是重要的项目,我越推荐你写单元测试。单元测试就是我们程序员的救生圈,在代码的海洋中为程序员提供安全感。有了单元测试的保障,程序员才有信心在约定时间内完成联调和发布,才敢对已有的程序作修改和重构而不担心引入新问题。

参考文章