单元测试实战(四种覆盖详解、测试实例)
理论部分
前言
单元测试,就是对某一段细粒度的Java代码的逻辑测试。代码块一般指一个Java 方法本身,所有外部依赖都需要mock掉,仅关注代码逻辑本身。
需要注意,单测的一个大前提就是需要清楚的知道自己要测试的程序块所预期的输入输出,然后根据这个预期和程序逻辑来书写case。
(这里需要注意的就是单测的预期结果 一定要针对需求/设计逻辑去写,而不是针对实现去写,否则单测将毫无意义,照着错误的实现设计出的case也很可能是错的)
覆盖类型
1、行覆盖 Statement Coverage
行覆盖(又叫语句覆盖)就是通过设计一定量的测试用例,保证被测试的方法每一行代码都会被执行一遍。
路径覆盖是最弱的覆盖方式。
实例:
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
本例仅需要一个case,即可实现行覆盖。test case 如下:
a | b | x | 预期结果 | |
TC1 | 2 | 3 | 6 |
@Test public void testFun3StatementCoverage(){ Integer res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); }
这个用例就可以保证所有的行都被执行。
但是仅仅有这一个用例的话,对这个方法的测试就是非常脆弱的。
举个栗子,某RD接到了这个需求,理清了逻辑,写好单测之后开始写代码(或者写好代码之后开始写单测)。但是由于手抖,将第三行的 && 写成了 ||:
public Integer fun4(Integer a, Integer b, Integer x) { if (a > 1 || b == 0) { x += a; } if (a == 2 || x > 1) { x += 1; } return x; }
然后跑一下单测,发现很顺滑,一下就过了。
随后该RD很高兴的将代码发布到了线上,结果就发生了严重的生产故障,于是该RD就被开除了。
行覆盖是一个最基础的覆盖方式,但是也是最薄弱的,如果完全依赖行覆盖,那不小心就会被开除。
2、判定覆盖 / 分支覆盖 (Decision Coverage/Branch Coverage)
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
判定覆盖的含义就是代码里每一个判定都要走一次true,一次false。依然用上面的代码,想要实现判定覆盖,需要以下case
a | b | x | 预期结果 | |
TC2 | 2 | 1 | 4 | |
TC3 | 3 | 1 | 1 | 1 |
@Test public void testFun3DecisionCoverage(){ Integer res = demoService.fun3(2,0,1); Assert.assertEquals(4,res.intValue()); res = demoService.fun3(3,1,1); Assert.assertEquals(1,res.intValue()); }
这两个用例可以保证判定 A: (a > 1 || b == 0) 和判定B: (a == 2 || x > 1) 分别都取一次true 和false:
tc2 时, A,B均为true;tc3时,A,B均为false。
可以看出分支覆盖依然有明显缺陷,并没有覆盖到 A: true B: false 和 A:false B:true的情况。
3、条件覆盖 Condition Coverage
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
条件覆盖和判定覆盖类似,不过判定覆盖着眼于整个判定语句,而条件覆盖则着眼于某个判断条件。
条件覆盖需要保证每个判断条件的true false都要覆盖到,而不是整个判定语句。
例如,判定A (a > 1 || b == 0) ,只需要整个判定表达式分别取一次真假即可满足判定覆盖,而要满足条件覆盖,则需要判断条件 (a>1) 和 (b==0) 分别都取一次true false才算满足。
依然采用同样的代码,要想实现条件覆盖,则需要:
a | b | x | 预期结果 | |
TC4 | 2 | 3 | 6 | |
TC5 | 1 |
@Test public void testFun3ConditionCoverage(){ Integer res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); res = demoService.fun3(0,1,0); Assert.assertEquals(0,res.intValue()); }
这两个用例可以保证 (a > 1) (b==0) (a == 2) (x > 1) 四个条件都分别取true false。
很明显可以发现,这玩意儿依然是不全面的,这个例子里条件覆盖和判定覆盖存在同样的问题,覆盖的不够全面。
4、路径覆盖 Path Coverage
public Integer fun3(Integer a, Integer b, Integer x) { if (a > 1 && b == 0) { x = x + a; } if (a == 2 || x > 1) { x += 1; } return x; }
路径覆盖这个顾名思义就是覆盖所有可能执行的路径。
为了方便理解,这里先把流程图画出来。
红色代表一段路径。
首先梳理所有路径:
路径1:1-->3-->5;
路径2:1-->2-->5;
路径3:1-->3-->4;
路径4:1-->2-->4;
路径覆盖就是需要设计用例,将所有的路径都走一遍。
设计以下用例:
a | b | x | 预期结果 | 经过路径 | |
TC6 | 1 | 1 | |||
TC7 | 3 | -3 | 2 | ||
TC8 | 2 | 1 | 3 | 4 | 3 |
TC9 | 2 | 3 | 6 | 4 |
@Test public void testFun3PathCoverage(){ Integer res = demoService.fun3(0,1,0); Assert.assertEquals(0,res.intValue()); res = demoService.fun3(3,0,-3); Assert.assertEquals(0,res.intValue()); res = demoService.fun3(2,1,3); Assert.assertEquals(4,res.intValue()); res = demoService.fun3(2,0,3); Assert.assertEquals(6,res.intValue()); }
总结
这是最常见的几种覆盖类型,行覆盖、判定覆盖、条件覆盖优缺点一致,都是较为简单,方便构造用例,并且能对程序质量有一定保证作用。
路径覆盖最完善,但是在一些复杂的场景里,会带来测试代码指数级增长的副作用,这个对于绝大多数人都是无法接受的。
一般情况下,行覆盖和判定覆盖同时做到已经是比较良好的单测代码了,jacoco的覆盖率统计也都是基于判定覆盖的。
实战部分(Mock)
mock是什么
事实上,理论和实际总是有差距的,单测也同样。
虽然单测的指导性理论知识非常完备,但是实际工作中往往遇到各种各样的障碍。最常见的,莫过于各种外部依赖了,这些外部依赖总是会阻碍我们书写自己的单测代码。这时候,我们就要用到mock了。
Mock这个单词的意思就是假的,模拟的。mock就是用一些特殊的代码,模拟一下外部依赖,将我们的测试代码与外部依赖解耦。
最直接的mock,就是重新写一个外部依赖的mock类,在里面返回mock的数据。测试的时候,将外部依赖手动替换成mock类。但是这样mock需要频繁修改代码,基本没什么实际价值。
为了解决mock外部依赖的需求,业界也出现了各种各样的mock框架。常见的有mockito、PowerMockito、Spock。
Spock:比较新的测试框架,基于groovy。优点:语法优雅清晰,缺点:groovy需要学习,没有经历时间的考验。
Mockito:最常用,最可靠的测试框架,优点就是没有什么太大的缺点。
PowerMock:为了解决Mockito等基于cglib的框架无法mock 私有、静态方法而产生的框架。
这里都采用Mockito来作为使用的测试框架。
简单Mock实战
整体代码详见:https://github.com/csonezp/mockdemo
这里就写一些关键代码。
service是最需要关注的地方,重要的逻辑一般都在这里;同时又有诸多的外部依赖,用这一层做实际mock的实例是最合适的。
/** * @author zhangpeng34 * Created on 2019/1/18 下午9:15 **/ @Service public class UserServiceImpl implements UserService { @Autowired UserDao userDao; @Override public User findUserById(Long id) { return userDao.findById(id).orElse(null); } @Override public User addUser(String name) { if(StringUtils.isEmpty(name)){ return null; } User user = new User(); user.setName(name); return userDao.save(user); } }
这个service很简单,这里针对里面的addUser方法写一些对应的单测:
/** * * 单元测试,测试的目的是对java代码逻辑进行测试。 * 单纯的逻辑测试,不应该加载外部依赖,所有的外部依赖应该mock掉,只关注本身逻辑。 * 例如,需要测试service层时,所依赖的dao等,应提前mock掉,设置好测试需要的输入和输出即可。 * dao层的逻辑应由dao层的测试保证,service层默认dao层是正确的。 **/ @RunWith(MockitoJUnitRunner.class) public class UserServiceTests { //mock注解创建一个被mock的实例 @Mock UserDao userDao; //InjectMocks代表创建一个实例,其他带mock注解的示例将被注入到该实例用。 //可以用该注解创建要被测试的实例,将实例所需的依赖用mock注解创建,即可mock掉依赖 @InjectMocks UserServiceImpl UserServiceImpl; String addUserName = "testAddUser"; /** * 初始化时设置一些需要mock的方法和返回值 * 这里的设置表示碰到userDao的save方法,且参数为任一User类的实例时,返回提前预设的值 */ @Before public void init(){ User user =new User(); user.setId(1L); user.setName(addUserName); Mockito.when(userDao.save(any(User.class))) .thenReturn(user); } //正向流程 @Test public void testAddUser(){ User user = UserServiceImpl.addUser(addUserName); Assert.assertEquals(addUserName,user.getName()); Assert.assertEquals(1L,user.getId().longValue()); } //异常分支,name为null @Test public void testAddUserNull(){ User user = UserServiceImpl.addUser(null); Assert.assertEquals(null,user); } //将各个分支都写出test和assert //............ }
集成测试
上面所说的都是单元测试,但是实际开发中,我们往往不光需要单元测试(甚至不需要单元测试。。。。),还需要有集成测试,来测试我们程序的整体运行情况。
集成测试并不是联调,集成测试用依然可以mock第三方依赖。
在我们的工程里,一般只要实际启动整个spring容器的测试代码,都是集成测试。
Controller层是较为适合集成测试的地方,这里用Controller层来做集成测试的示例。
@RestController public class UserController { @Autowired UserService userService; @PostMapping(value = "/user") public Object register(String name) { return userService.addUser(name); } @GetMapping(value = "/user/{userId}") public Object getUserInfo(@PathVariable Long userId) { User user = userService.findUserById(userId); if (user != null) { return user; } return "fail"; } @PostMapping(value = "/user/login") public Object login(Long id, String pwd) { User user = userService.findUserById(id); if(user!=null){ return user; } return "fail"; } }
下面写一个这次集成测试用的spring测试文件,位置如下:
测试代码:
配置文件如下:
spring.profiles=it server.port=9898 spring.h2.console.enabled=true spring.h2.console.path=/h2 spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 spring.datasource.username=sa spring.datasource.password=sa spring.datasource.max-wait=10000 spring.datasource.max-active=5 spring.datasource.test-on-borrow=true spring.datasource.test-while-idle = true spring.datasource.validation-query = SELECT 1 # jpa spring.jpa.hibernate.ddl-auto=update #spring.jpa.hibernate.ddl-auto= create-drop spring.jpa.hibernate.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy spring.jpa.show-sql=true spring.jpa.generate-ddl=true
这个配置文件的主要作用就是将程序连接的DB换成一个内存数据库h2,这样就不用在测试时受DB的掣肘了。
@RunWith(SpringRunner.class) @SpringBootTest @ActiveProfiles("it") @AutoConfigureMockMvc public class UserControllerTests { @Autowired private MockMvc mvc; @Autowired UserDao userDao; Long userId; @Before public void init(){ User user = new User(); user.setName("111"); userId = userDao.save(user).getId(); System.out.println(userId); } /** * 测试/user/{userId} * @throws Exception */ @Test public void testGetUser() throws Exception { //success this.mvc.perform(get("/user/"+userId)).andExpect(status().isOk()) .andExpect(content().json("{ \"id\": "+userId+", \"name\": \"111\" }")); //fail this.mvc.perform(get("/user/"+(userId+100))).andExpect(status().isOk()) .andExpect(content().string("fail")); } /** * 测试login * @throws Exception */ @Test public void exampleTest2() throws Exception { //success this.mvc.perform(post("/user/login").param("id",userId.toString()).param("pwd","11")) .andExpect(status().isOk()) .andExpect(content().json("{ \"id\": "+userId+", \"name\": \"111\" }")); //fail this.mvc.perform(post("/user/login").param("id",userId.toString()+"11").param("pwd","11")) .andExpect(status().isOk()) .andExpect(content().string("fail")); } }
@ActiveProfiles("it")
这个注解就是制定本测试代码加载的配置文件,”it“指 文件名 application-XX.properties
中间的xx,spring会自动根据名称去加载对应的配置文件。
init()
方法就是在内存数据库中构造自己需要的数据,这是集成测试最常见的步骤。
后面的测试代码就不需要解释太多了。