一、基本概念
1.什么是单元测试
单元测试 是针对 程序的最小单元 来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。一个单元可能是 单个程序、类、对象、方法 等。——维基百科
2.金字塔模型
在金字塔模型前,流行的是 冰淇淋模型 。
包含了大量的手工测试、端到端的自动化测试及少量的单元测试。造成的后果是,随着产品壮大,手工回归测试时间越来越长,质量很难把控;自动化case频频失败,每一个失败对应着一个长长的函数调用,到底哪里出了问题?单元测试少的可怜,基本没作用。
Mike Cohn 在他的着作《Succeeding with Agile》一书中提出了 “测试金字塔” 这个概念。
越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。
因为涉及到的模块过多,任何一个模块做了调整,都有可能破坏高层测试,所以,高层测试通常是相对比较脆弱的,在实际的工作中,有些高层测试会牵扯到外部系统,这样一来,复杂度又在不断地提升。
3.为什么需要单测
1)验证我们代码的正确性
2)保证修改(重构)后代码的正确性
很多时候我们不敢修改(重构)老代码的原因,就是不知道它的影响范围,担心其它模块因为依赖它而不工作,有了单元测试之后,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的修改(重构)代码。
3)单元测试的性价比是最高的
错误发现的越晚,修复它的成本就越高,而且呈指数增长趋势
4)可以加深我们对业务的理解
写单测的过程其实就是设计测试用例的过程,需要考虑业务的各种场景,从而可以使我们跳出代码来思考业务,这样可以反过来思考我们的代码是否满足业务的需求
4.基本指导原则
1)AIR原则
单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。
单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。
保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 反例:method2需要依赖method1的执行,将执行结果作为method2的输入。
单元测试是可以重复执行的,不能受到外界环境的影响。 说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。 正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。
2)BCDE原则
编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量
包括循环、 特殊取,边界值测试包括循环、 特殊取特殊时间点、数据顺序等
正确的输入并得到预期结果
与设计文档相结合来编写单元测试
强制错误信息输入(如:非法数据、异常流程业务允许等),并得到预期结果
二、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
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 私有方法是否需要单元测试
私有方法有很多好处,然而私有方法是很难进行单元测试的,或者说需要付出更多的代码进行测试。一般不推荐对私有方法单独进行测试,而是通过测试调用它们的公有方法进行测试。
通常私有方法不要处理过多的业务逻辑,最好只是简单的数据处理的工具方法,否则代码结构可能不合理。当发现代码不好写单元测试时,很有可能是提示你要重构你的代码了。