一、基本概念

1.什么是单元测试

单元测试 是针对 程序的最小单元 来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。一个单元可能是 单个程序、类、对象、方法 等。——维基百科

2.金字塔模型

在金字塔模型前,流行的是 冰淇淋模型

包含了大量的手工测试、端到端的自动化测试及少量的单元测试。造成的后果是,随着产品壮大,手工回归测试时间越来越长,质量很难把控;自动化case频频失败,每一个失败对应着一个长长的函数调用,到底哪里出了问题?单元测试少的可怜,基本没作用。

Mike Cohn 在他的着作《Succeeding with Agile》一书中提出了 “测试金字塔” 这个概念。

  • 编写不同粒度的测试
  • 层次越高,你写的测试应该越少
  • 越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。

  • 比如 单元测试 ,它的关注点只有一个单元,而没有其它任何东西。所以,只要一个单元写好了,测试就是可以通过的;
  • 集成测试 则要把好几个单元组装到一起才能测试,测试通过的前提条件是,所有这些单元都写好了,这个周期就明显比单元测试要长;
  • 系统测试 则要把整个系统的各个模块都连在一起,各种数据都准备好,才可能通过。
  • 因为涉及到的模块过多,任何一个模块做了调整,都有可能破坏高层测试,所以,高层测试通常是相对比较脆弱的,在实际的工作中,有些高层测试会牵扯到外部系统,这样一来,复杂度又在不断地提升。

    3.为什么需要单测

    1)验证我们代码的正确性

  • 我们写完代码通常要自己测试验证一番才会交付给QA进行测试。通常自我测试的方法就是跑一些程序,简单测试一下其中主要的分支场景,如果通过就认为自己的代码没有问题可以交付给QA了。
  • 但事实上运行代码是很难测试一些特殊场景或者覆盖全部分支条件,比如很难模拟IOException,数据库访问异常等场景,或者穷尽各种边界条件等。
  • 而我们通过单元测试可以很轻松的构建各种测试场景,从而几乎100%确认我们的代码是可以交付给QA的。
  • 2)保证修改(重构)后代码的正确性

    很多时候我们不敢修改(重构)老代码的原因,就是不知道它的影响范围,担心其它模块因为依赖它而不工作,有了单元测试之后,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的修改(重构)代码。

    3)单元测试的性价比是最高的

    错误发现的越晚,修复它的成本就越高,而且呈指数增长趋势

    4)可以加深我们对业务的理解

    写单测的过程其实就是设计测试用例的过程,需要考虑业务的各种场景,从而可以使我们跳出代码来思考业务,这样可以反过来思考我们的代码是否满足业务的需求

    4.基本指导原则

    1)AIR原则

    单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。

  • A:Automatic(自动化)
  • 单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。

  • I:Independent(独立性)
  • 保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 反例:method2需要依赖method1的执行,将执行结果作为method2的输入。

  • R:Repeatable(可重复)
  • 单元测试是可以重复执行的,不能受到外界环境的影响。 说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。 正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。

    2)BCDE原则

    编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量

  • B:Border(边界值测试)
  • 包括循环、 特殊取,边界值测试包括循环、 特殊取特殊时间点、数据顺序等

  • C:Correct(正确的输入)
  • 正确的输入并得到预期结果

  • D:Design(与设计文档相结合)
  • 与设计文档相结合来编写单元测试

  • E:Error(强制错误信息输入)
  • 强制错误信息输入(如:非法数据、异常流程业务允许等),并得到预期结果

    二、Mock

    1.Mock工具对比

    Mock工具 功能全面性 可读性
    Mockito 动态代理方式生成Mock对象,只能在方法前后环绕,因此static, final, private方法均不能mock。 语法简介,步骤少,省略了回放步骤
    PowerMock 通过修改字节码的方式Mock对象,功能全面,需要配合EasyMock或Mockito使用。 扩展功能,结合EasyMock和Mockito
    JMockit 通过修改字节码的方式Mock对象,功能全面。 语法繁琐,使用起来不够便捷,可读性不高
    EasyMock 动态代理方式生成Mock对象,static, final, private方法均不能mock。 语法简介,步骤依然偏多,包含录制、回放、检查三步来完成大体的测试过程

    为什么选择Mockito

  • 语法简洁易于上手
  • static,final,private方法不能mock,可以通过PowerMock来补充
  • 2.Mockito使用

    0) demo类库准备

    @Data
    @AllArgsConstructor
    public class User {
        private Integer id;
        private String name;
        private Integer age;
    @Mapper
    @Component
    public interface UserMapper {
        @Select("select * from user")
        List<User> findUser();
        @Select("select * from user where id=#{id}")
        User findUserById(@Param("id")Integer id);
        @Update("update user set id=#{id},name=#{name},age=#{age}")
        boolean update(User user);
    @Service
    @Transactional
    public class UserService {
        @Autowired
        private UserMapper userMapper;
        public boolean update(int id, String name,int age) {
            User user = userMapper.findUserById(id);
            if (Objects.isNull(user)) {
                return false;
            User userUpdate = new User(id, name,age);
            return userMapper.update(userUpdate);
    

    1)依赖引入

    <dependency>
    	<groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>2.10.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
    	<groupId>org.powermock</groupId>
        <artifactId>powermock-api-mockito2</artifactId>
        <version>2.0.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
    	<groupId>org.powermock</groupId>
        <artifactId>powermock-module-junit4</artifactId>
        <version>2.0.2</version>
        <scope>test</scope>
    </dependency>
    

    2)mock对象的构建与基本语法

    a.创建mock对象

  • @InjectMocks:创建一个实例,简单的说是这个Mock可以调用真实代码的方法,其余用@Mock(或Spy)注解创建的mock将注入到该实例中
  • @Mock:对函数的调用均执行mock(即虚假函数),不执行真正部分
  • @Spy:对函数的调用均执行真正部分。(即如果一个方法没有被when return 定义,会执行真正的方法)
  • b.配置mock对象

  • when().thenReturn(): 定义一个行为,当调用某个方法时,返回某个值
  • doThrow().when(x).method:调用了x.method方法后,抛出异常Exception
  • c.校验mock对象的方法调用

  • verify(object, atLeastOnce()).method():至少被调用了1次
  • verify(object, times(1)).method():被调用了1次
  • verify(object, times(3)).method():被调用了3次
  • verify(object, never()).method():从未被调用
  • @RunWith(MockitoJUnitRunner.class)
    @Slf4j
    public class TestUser {
        @Mock
        private UserMapper userMapper;
        //注意这里需要注入userMapper,需要用InjectMocks注解
        @InjectMocks
        private UserService userService;
        @Before
        public void setUp() throws Exception {
            when(userMapper.findUserById(1)).thenReturn(new User(1, "Person1",18));
            when(userMapper.update(isA(User.class))).thenReturn(true);
        @Test
        public void testUpdate() throws Exception {
            boolean result = userService.update(1, "new name",18);
            assertTrue("must true", result);
            //验证是否执行过一次findUserById(1)
            verify(userMapper, times(1)).findUserById(eq(1));
            //验证是否执行过一次update
            verify(userMapper, times(1)).update(isA(User.class));
        @Test
        public void testUpdateNotFind() throws Exception {
            boolean result = userService.update(2, "new name",20);
            assertFalse("must false", result);
            //验证是否执行过一次findUserById(1)
            verify(userMapper, times(1)).findUserById(eq(1));
            //验证是否从未执行过update
            verify(userMapper, never()).update(isA(User.class));
        @Test
        public void testException() throws Exception {
            doThrow(new NullPointerException()).when(userMapper).findUserById(1);
            userMapper.findUserById(1);
    

    3)集合与参数捕获器

    captor用来保存输入参数

    由于list没有制定size()的行为,所以其结果为null。因为mockito的底层原理是使用cglib动态生成一个代理类对象,因此,mock出来的对象其实质就是一个代理,该代理在没有配置/指定行为的情况下,默认返回空值

    @RunWith(MockitoJUnitRunner.class)
    @Slf4j
    public class TestList {
        @Mock
        List mockedList;
        @Captor
        ArgumentCaptor argumentCaptor;
        @Test
        public void whenUseCaptorAnnotation_thenTheSam() {
            mockedList.add("one");
            Mockito.verify(mockedList).add(argumentCaptor.capture());
            log.info("size:{}", mockedList.size());
            log.info("mockedList first:{}", mockedList.get(0));
            assertEquals("one", argumentCaptor.getValue());
    

    4)mock静态方法

    mockito不支持静态方法,需要使用PowerMock

    @RunWith(PowerMockRunner.class)//1
    @Slf4j
    @PrepareForTest({MyStringUtil.class})//2
    public class TestUtil {
        @Before
        public void before() {
            PowerMockito.mockStatic(MyStringUtil.class);//3
        @Test
        public void test() throws IOException {
            PowerMockito.when(MyStringUtil.uppercase("abc")).thenReturn("ABC");//4
            assertEquals("ABC", MyStringUtil.uppercase("abc"));//5
    class MyStringUtil {
        public static String uppercase(String s) {
            return s.toUpperCase();
    

    三、dao层测试

    DAO 层的测试难点主要在解除数据库这一外部依赖上

    1.可选方案

  • 内存数据库h2
  • mysql新建一个供测试使用的库
  • TestContainer使用docker进行数据库实例管理
  • 2.对比优劣

  • 内存数据库sql语法与真实数据库不同
  • mysql新建测试使用的库违背了单测原则,同时存在并发问题
  • 它们都不能模拟包括redis,mq等中间件
  • 3.内存数据库h2

  • 不支持mysql的批量更新功能,只支持批量插入
  • 不支持mysql的replace into语法
  • 整个数据库中不允许出现相同唯一索引
  • 不支持表级别的comment
  • 4.TestContainer(基于docker)

    首先需要安装docker和指定版本的mysql镜像

    1)依赖引入

    <dependency>
    	<groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.12.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
    	<groupId>org.testcontainers</groupId>
        <artifactId>mysql</artifactId>
        <version>1.12.0</version>
        <scope>test</scope>
    </dependency>
    

    2)sql脚本

    init.sql脚本文件(DDL语句)

    CREATE TABLE `user` (
      `name` varchar(255) DEFAULT NULL,
      `age` int(11) DEFAULT NULL,
      `id` int(11) NOT NULL AUTO_INCREMENT,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    insert into `user` (`name`,`age`) values ('lisi',30);
    insert into `user` (`name`,`age`) values ('wangwu',30);
    insert into `user` (`name`,`age`) values ('赵六',15);
    

    3)测试代码

    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringBootTest
    @Slf4j
    public class TestDemo3 {
        @Autowired
        private UserMapper userMapper;
        @ClassRule
        public static MySQLContainer mysql = (MySQLContainer) new MySQLContainer("mysql:5.7")
                .withInitScript("db/init.sql")
                .withCommand("--character-set-server=utf8 --collation-server=utf8_unicode_ci");
        @BeforeClass
        public static void initMysql() {
            System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
            System.setProperty("spring.datasource.driver-class-name", mysql.getDriverClassName());
            System.setProperty("spring.datasource.username", mysql.getUsername());
            System.setProperty("spring.datasource.password", mysql.getPassword());
        @Test
        public void testUserMapper() {
            List<User> userList = userMapper.findUser();
            assert userList.size() == 3;
            log.info("查询到的用户列表如下::[{}]", userList);
    

    四、单测报告、覆盖率报告

    1.maven

    2.1 依赖部分

    <dependency>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.6</version>
    </dependency>
    

    2.2 插件部分

    <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.0.0-M5</version>
                    <configuration>
                        <skipTests>false</skipTests>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-deploy-plugin</artifactId>
                    <configuration>
                        <skip>false</skip>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.jacoco</groupId>
                    <artifactId>jacoco-maven-plugin</artifactId>
                    <version>0.8.6</version>
                    <configuration>
                        <skip>false</skip>
                    </configuration>
                    <executions>
                        <execution>
                            <goals>
                                <goal>prepare-agent</goal>
                            </goals>
                        </execution>
                        <execution>
                            <configuration>
                                <outputDirectory>${basedir}/target/coverage-reports</outputDirectory>
                            </configuration>
                            <id>report</id>
                            <phase>test</phase>
                            <goals>
                                <goal>report</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    

    2.覆盖率报告

  • Missed Instructions:字节码指令的指令覆盖率
  • Missed Branches:分支覆盖率,所有行覆盖不等于所有分支覆盖
  • 如下图,urls == null 的条件没有被覆盖,分支覆盖率不是100%

  • Missed Cxty:圈复杂度,包括if-else, switch-case, while, for
  • Missed.Lines、Methods、classes
  • Classes表示类、Methods表示方法、Lines表示代码行。

    Missed表示未覆盖数量,Classes表示共有X个类、Methods表示共有X个方法,Lines表示共有多少行代码(例如:else是不统计到Lines的)。

    五、一些问题

    5.1 先写单测还是后写单测

    先写代码后写单测,其实是为了保证或者验证我们代码的正确性,先写单测后写代码也就是测试驱动开发(TDD),其实是从业务角度驱动开发,思考的方式完全不同。

  • 先写代码后写单测,很可能落入照着代码写单测的误区,这样做只能保证写出来的代码都是对的,并不能保证没有漏掉一些分支条件。
  • 而测试驱动是从业务场景出发,是真正意义上的先设计测试用例,然后写代码。
  • 如果不是按照先写测试后写呗测试程序的红、绿、重构方法原则,测试编写很可能会变成一种体力劳动,很多开发人员在开发完某个功能后才去写测试方法,把这当成一种在提交代码前需要完成的任务。

    5.2 返回值为void测什么

    返回值为void,说明方法没有出参,那方法内部必然有一些行为,它可能是**「改变了内部属性的值」,也可能是「调用了某个外部类的方法」**。

  • 如果是改变内部的某个值,那可以通过对象的get参数来断言。这在使用DDD后的领域模型是一个问题,因为有可能本来产品代码不需要暴露出get方法的,但由于测试需要,暴露出了内部属性的get方法。虽然使用反射也可以拿到内部属性的值,但没有太大必要,权衡利弊,还是暴露领域模型的get方法好一点。
  • 如果是调用某个外部的方法,可以用verify来验证是否调用了某个方法,可以用capture验证调用其它方法的入参,这样也可以验证产品代码是否如自己预期的设计在工作。
  • 5.3 私有方法是否需要单元测试

    私有方法有很多好处,然而私有方法是很难进行单元测试的,或者说需要付出更多的代码进行测试。一般不推荐对私有方法单独进行测试,而是通过测试调用它们的公有方法进行测试。

    通常私有方法不要处理过多的业务逻辑,最好只是简单的数据处理的工具方法,否则代码结构可能不合理。当发现代码不好写单元测试时,很有可能是提示你要重构你的代码了。

    从头到脚说单测——谈有效的单元测试

    有赞单元测试实践