单元测试知识点全家桶——基本概念与最佳实践

2,331 阅读12分钟

一、基本概念

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 私有方法是否需要单元测试

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

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

参考资料

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

有赞单元测试实践