尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JUnit4+Mockito业务层单元测试七步法实战指南

JUnit4+Mockito业务层单元测试七步法实战指南 做Java后端开发的朋友应该都有过这种体验业务层的单元测试看起来很简单可真正写起来要么不知道该Mock什么要么一跑就报空指针要么测试写完比业务代码还难维护。很多团队对单测的态度是“写了就行”结果写出来的测试只是把线上跑通的接口原样复现一遍根本不测边界条件。我最近在整理团队内部的业务层单测规范顺手把Junit4 Mockito这套组合的通用流程沉淀了一下。今天这篇文章把我踩过的坑和实际跑通的代码全部分享出来希望能帮你建立一套稳定的业务层测试套路。不管你是刚接触单测的新人还是写了不少测试但总感觉没底的朋友都可以照着文章里的步骤走一遍。1. 为什么业务层单元测试这么难写——问题背景与方案选型1.1 业务层测试的核心痛点业务层是系统中最容易被“高估”的一层。很多人觉得Service方法就是Controller调用的入口参数进来、结果出去中间无非是调几个Mapper似乎很好测。可真到动手写测试的时候会发现完全不是那么回事。首先业务层的依赖链条非常长。一个典型的Service类里往往同时注入了Mapper、RedisTemplate、消息发送客户端、外部HTTP服务甚至还有一堆配置属性。如果按照“启动Spring容器再测试”的老思路来做每次跑测试都要把整个上下文加载一遍慢得让人崩溃而且数据库、Redis等外部环境一旦不可用测试就跟着失败。这根本不符合单元测试“快速、稳定、可重复”的目标。其次业务逻辑里的分支太多。单元测试的核心是控制输入、锁定输出可业务方法里到处都是if/else、try/catch、状态流转甚至还有事务回滚。如果每个分支都去真实调用依赖不仅数据准备麻烦还很容易出现“上一次测试留下的数据污染了下一次测试”的情况。很多团队最后放弃业务层单测不是因为不会写而是因为写起来又慢又脆维护成本远超收益。还有一个隐蔽的痛点不知道Mock的边界在哪里。有人喜欢把所有依赖全部Mock掉连一个返回的实体类都Mock结果业务里调用entity.getXxx()全部返回null测试的目的变成了验证“Mock能不能正常工作”也有人反过来觉得某些依赖“太难Mock”干脆直接new一个真实实现结果把下游问题也一起测了测试失败根本定位不到是当前类的问题。1.2 为什么选 Junit4 Mockito说实话Junit4本身已经算是“老古董”了但存量Java项目里用它的比例依然很高。很多团队选型时要考虑迁移成本升级Junit5意味着所有测试注解、运行器、断言方式都要跟着调整这在老项目里是个不小的工程。相比之下Junit4和Mockito的组合足够成熟社区资料多踩坑案例也多是业务层单测稳定性最高的一套方案。Mockito的核心能力是“制作替身”。它可以用Mock创建一个假的依赖对象再用InjectMocks把这个假依赖注入到被测类里。这样一来被测类依然正常运行但它依赖的Mapper、Redis、短信服务全部由我们自己控制返回值或异常行为。测试就不再依赖真实环境也不再受外部网络、数据库状态的影响。我用Mockito还有一个很直观的感受它的语法和阅读顺序符合人的思维方式。先given配置行为再when调用业务方法最后then验证结果和交互写出来的测试几乎可以当需求文档读。相比直接启动SpringBootTest写一堆上下文注解这种轻量级方式在速度上快了好几个数量级。1.3 整体流程概述这套通用流程我称之为“七步法”梳理被测类依赖初始化Mock对象并注入配置Mock行为构造测试数据调用被测方法断言返回结果验证依赖交互。七步走完正常路径、分支路径、异常路径都能覆盖到。后面章节我会先把环境搭建和依赖配置说清楚再讲怎么写“可测试的业务代码”然后用完整案例演示七步法的每一个动作最后把常见问题整理成速查表。整个过程不涉及PowerMock、不启动Spring容器、不碰真实数据库用纯Junit4 Mockito解决业务层90%以上的单测场景。2. 环境搭建与基础依赖配置2.1 Maven依赖如何配置假设你是个标准Maven项目只需要在pom.xml里加上Junit和Mockito两个依赖。我建议Junit直接上4.13.2这是Junit4最后一个比较稳定的版本包含了assertThrows等很实用的API。Mockito用3.x系比如3.12.4兼容JDK8以上Byte Buddy的依赖会自动传递不需要额外处理。properties junit.version4.13.2/junit.version mockito.version3.12.4/mockito.version /properties dependencies dependency groupIdjunit/groupId artifactIdjunit/artifactId version${junit.version}/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version${mockito.version}/version scopetest/scope /dependency /dependencies如果你的项目本身是Spring Boot工程通常已经有了spring-boot-starter-test它默认会带Junit4或Junit5以及Mockito。这种情况下不用重复添加依赖但要特别留意版本冲突。之前我遇到过一个项目Spring Boot自带的Mockito版本和项目中另外引入的PowerMock版本不兼容跑测试时各种NoSuchMethodError最后只能统一版本。建议在parent里显式声明mockito-core的版本避免Spring Boot间接依赖覆盖。如果是JDK11及以上环境Mockito 3.12.4会遇到一个隐蔽问题Mockito无法通过反射访问JDK内部的某些类。解决办法是升级到Mockito 4.x或者在运行测试时加JVM参数。不过我个人在公司新项目里已经用Mockito 4.11.0了JDK17下也没出过问题。存量项目如果只跑JDK8用3.x更省心。2.2 基础注解与测试类骨架业务层测试类的骨架很固定。通常在类上标注RunWith(MockitoJUnitRunner.Silent.class)然后给每个依赖加Mock给被测类加InjectMocks。这样做的好处是不用手动写MockitoAnnotations.initMocks(this)runner会帮你完成所有Mock对象的初始化和字段注入。RunWith(MockitoJUnitRunner.Silent.class) public class UserServiceTest { Mock private UserMapper userMapper; Mock private RedisTemplateString, String redisTemplate; InjectMocks private UserServiceImpl userService; // 测试方法 }这里有几个细节很关键。第一个是RunWith(MockitoJUnitRunner.class)和RunWith(MockitoJUnitRunner.Silent.class)的区别。前者会在测试结束时检查是否存在“多余的Stub”也就是你配置了某个Mock行为但测试中从未用到这时会抛UnnecessaryStubbingException。这个检查本身很有价值能帮你发现冗余代码但新手刚使用时经常觉得莫名其妙所以我建议先用Silent版本把流程跑通等熟练后再切回严格模式。第二个细节是InjectMocks的注入方式Mockito会先尝试构造器注入再尝试setter注入最后才是字段注入。如果被测类有多个构造器Mockito会“智能”选择一个但如果你不确定最好在Before方法里手动new一个被测类把Mock对象通过构造器传进去。手动注入虽然多几行代码但可控性最好我后面会再展开说明。2.3 一个最简单的业务层测试长什么样先看一个最贴近真实的例子。假设UserServiceImpl有一个方法根据用户ID查询用户信息内部只依赖UserMapperpublic class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override public User getUserById(Long id) { return userMapper.findById(id); } }对应测试类RunWith(MockitoJUnitRunner.Silent.class) public class UserServiceImplTest { Mock private UserMapper userMapper; InjectMocks private UserServiceImpl userService; Test public void getUserById_shouldReturnUser() { User user new User(); user.setId(1L); user.setUsername(zhangsan); when(userMapper.findById(1L)).thenReturn(user); User result userService.getUserById(1L); assertNotNull(result); assertEquals(zhangsan, result.getUsername()); verify(userMapper).findById(1L); } }这个测试虽然简单但七步法中“配置Mock行为”“调用方法”“断言结果”“验证交互”都有了。运行它不需要数据库、不需要Spring容器速度极快。你可能觉得这么简单的东西没必要写测试但恰恰是这种基础能力让后面复杂业务场景的测试变得有章可循。3. 让业务代码可测试依赖注入与Mock边界3.1 依赖注入是前提单元测试能不能顺利写下去很大程度上取决于业务代码本身的设计。最让人头疼的就是在Service里用new关键词去创建依赖对象。比如private final SmsClient smsClient new SmsClient();这样写测试类里根本没有办法替换掉smsClient只要SmsClient内部有网络调用你的单测就变成了一个“不稳定的集成测试”。所以我在写业务代码时统一要求使用构造器注入Spring推荐的做法也是构造器注入Spring Boot 3.x甚至把字段注入标记为不推荐。对比一下糟糕和正确的写法// 不推荐字段注入 Service public class UserServiceImpl { Autowired private UserMapper userMapper; Autowired private SmsClient smsClient; } // 推荐构造器注入 Service public class UserServiceImpl { private final UserMapper userMapper; private final SmsClient smsClient; public UserServiceImpl(UserMapper userMapper, SmsClient smsClient) { this.userMapper userMapper; this.smsClient smsClient; } }构造器注入的另一个好处是强制你思考依赖的数量。如果一个Service的构造器里有七八个参数那基本可以断定这个类违反了单一职责原则。测试写起来困难反而是在提示你该拆分了。3.2 识别需要Mock的边界写业务层单测时最重要的判断是当前测试关注的是哪个类的哪一段逻辑以UserServiceImpl为例我要测的是register方法里的业务规则比如“用户名不能为空”“用户名不能重复”“注册成功后写缓存、发短信”。UserMapper、RedisTemplate、SmsClient都是UserServiceImpl的协作者它们的行为不受当前类控制所以全部Mock掉。但有一些情况不需要Mock。比如返回结果是纯POJO像User、Order这类只有getter/setter的对象直接用真实对象构造就行。如果对POJO也Mock业务代码里调用user.getUsername()会返回null测试就无法反映真实行为。再比如纯静态工具类里没有任何I/O和外部状态如果必须mock静态方法说明这个工具类设计有问题建议改造而不是强行绕过去。判断是否要Mock还可以问自己一个问题这个依赖会不会导致测试结果不稳定真实数据库、Redis、HTTP服务都会让结果不稳定而一个简单的字符串工具方法不会。凡是不稳定的外部依赖都需要Mock。3.3 设计测试用例的输入边界很多新手写测试只写一个“happy path”方法调用成功断言一下非空然后就算完成了。但实际上业务层最容易出错的反而不是主流程而是各种边界情况。设计测试用例时我会把业务方法的输入分为几类正常输入、空值、非法值、边界值、存在冲突的数据、底层依赖异常。以用户注册为例场景输入特点预期结果注册成功用户名合法且不重复返回用户DTO写缓存发短信用户名为空null或空白字符串抛出IllegalArgumentException用户名已存在数据库已有同名用户抛出BizException不插入新数据短信服务异常smsClient抛异常抛出BizException并回滚或标记后置失败数据库插入失败userMapper.insert返回0或抛错抛出异常不继续后续动作每个业务方法至少要有正常、异常、边界三个用例。这样写出来的测试才不是形式主义。4. 通用七步法从梳理依赖到异常验证4.1 梳理被测类依赖并初始化Mock无论业务类多复杂第一步永远是搞清楚它依赖谁。打开Service类看它的字段和构造器把外部依赖列出来。这里的“外部依赖”不仅包括Spring Bean还包括一些基础组件。public class UserServiceImpl implements UserService { private final UserMapper userMapper; private final RedisTemplateString, String redisTemplate; private final SmsClient smsClient; }依赖清单UserMapper数据库访问接口需要MockRedisTemplate缓存组件需要MockSmsClient外部短信服务需要Mock然后创建测试类在字段上逐个标注Mock在类名上标注RunWith被测类用InjectMocks。如果遇到依赖是同类型的情况比如同一个Service里注入了两个CacheService实现可以用Mock(name userCacheService)加字段名来匹配但更稳妥的是在Before里手动构造被测类Before public void setUp() { userService new UserServiceImpl(userMapper, redisTemplate, smsClient); }手动注入的代码一点也不难写但可以完全避免Mockito对注入策略的“自作主张”。我建议团队统一采用手动注入减少神奇行为。4.2 配置Mock行为与准备测试数据初始化完成后第二步是明确“Mock对象在调用什么参数时返回什么结果”。这个动作叫StubbingMockito里最常用的方法是when().thenReturn()when(userMapper.findByUsername(zhangsan)).thenReturn(null); when(userMapper.insert(any(User.class))).thenAnswer(invocation - { User user invocation.getArgument(0); user.setId(1L); return 1; }); ValueOperationsString, String valueOps mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(valueOps);有几个容易踩的坑如果Mock的方法返回void就不能用when().thenReturn()要用doNothing().when()或doThrow().when()。如果Mock的方法在业务里会被调用多次thenReturn可以连续写多个返回值比如thenReturn(null).thenReturn(user)表示第一次返回null第二次返回user。如果希望返回值每次都不一样用thenAnswer比thenReturn更直观。同时准备测试数据我建议写一个buildXxx的私有方法不要在每个测试方法里堆一堆set方法。比如private User buildUser(Long id, String username) { User user new User(); user.setId(id); user.setUsername(username); return user; }这样既减少了重复代码也让每个测试方法读起来更清晰。4.3 执行被测方法并断言结果Stub配置好、数据准备好接下来就是执行业务方法。这一步最需要注意的就是“只执行一次真正的方法调用”不要为了验证分支反复调用。执行完以后立刻做断言。Junit4常用断言有这么几种assertEquals(expected, actual); assertTrue(condition); assertFalse(condition); assertNull(obj); assertNotNull(obj); assertThrows(BizException.class, () - userService.register(request));这里我特别推荐assertThrows它在Junit4.13里已经提供比Test(expected Xxx.class)更好用因为它能精确定位到具体某一行抛出了异常还能对异常对象做进一步断言BizException ex assertThrows(BizException.class, () - userService.register(request)); assertEquals(USER_EXIST, ex.getCode());断言不能只写一个assertNotNull。如果返回值是UserDTO至少要断言ID和用户名如果方法返回boolean要明确断言true还是false。把断言想象成“验收标准”标准越具体回归测试的价值越高。4.4 验证依赖交互与异常场景很多业务方法返回void比如删除用户、发送验证码。这种方法的测试核心不是返回值而是“被测方法是否正确地调用了依赖”。Mockito提供了verify机制来验证交互verify(userMapper).deleteById(1L); verify(redisTemplate).delete(user:1); verify(smsClient, never()).sendWelcome(anyString()); verify(userMapper, times(2)).findById(1L); verify(redisTemplate, timeout(1000)).delete(user:1);times(1)可以简写成不传参数。atLeastOnce()、atMost(2)等也可以按需使用。但要注意verify验证的是一次测试方法执行周期如果业务方法内部循环调用了两次Mappertimes(2)才是对的不要凭感觉写。异常场景的验证分两种情况一种是异常由当前业务方法主动抛出比如参数校验失败这时用assertThrows另一种是依赖抛出的异常被当前类捕获后转为日志或自定义异常这时要在Stub阶段用doThrow模拟依赖异常再断言业务层的处理结果。比如doThrow(new RuntimeException(sms connect fail)) .when(smsClient).sendWelcome(anyString()); BizException ex assertThrows(BizException.class, () - userService.register(request)); assertEquals(AFTER_HOOK_ERROR, ex.getCode());一个方法里涉及的所有外部交互只要对业务结果有影响就应该在测试中显式验证。这样测试才能真正锁定行为避免别人后来加了一行重复调用也没有测试发现。5. 实战用户注册接口的Junit4Mockito测试5.1 业务代码示例前面讲了不少方法论这一章用一个完整的用户注册需求来跑一遍“七步法”。业务代码如下Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; private final RedisTemplateString, String redisTemplate; private final SmsClient smsClient; public UserServiceImpl(UserMapper userMapper, RedisTemplateString, String redisTemplate, SmsClient smsClient) { this.userMapper userMapper; this.redisTemplate redisTemplate; this.smsClient smsClient; } Override public UserDto register(RegisterRequest request) { if (request null || request.getUsername() null || request.getUsername().trim().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } String username request.getUsername().trim(); if (userMapper.findByUsername(username) ! null) { throw new BizException(USER_EXIST, 用户名已存在); } User user new User(); user.setUsername(username); user.setPhone(request.getPhone()); userMapper.insert(user); try { redisTemplate.opsForValue().set(user: user.getId(), user.getUsername()); smsClient.sendWelcome(user.getPhone()); } catch (Exception ex) { throw new BizException(AFTER_HOOK_ERROR, 注册后置动作失败, ex); } UserDto dto new UserDto(); dto.setId(user.getId()); dto.setUsername(user.getUsername()); dto.setPhone(user.getPhone()); return dto; } }这个例子包含了几乎所有常见的测试点参数校验、依赖查询、幂等校验、数据库插入、外部缓存、短信发送、异常转译。虽然业务本身简单但对测试来说足够典型。5.2 测试代码编写全过程先创建测试类初始化四个Mock对象然后按场景写测试方法。第一个场景是注册成功。这里要注意userMapper.insert之后user.getId()需要被自动回填。Mapper接口典型的MyBatis行为是insert成功会把主键设置到传入的领域对象上所以需要在Stub中用thenAnswer模拟这个副作用。RunWith(MockitoJUnitRunner.Silent.class) public class UserServiceImplTest { Mock private UserMapper userMapper; Mock private RedisTemplateString, String redisTemplate; Mock private SmsClient smsClient; private UserServiceImpl userService; Before public void setUp() { userService new UserServiceImpl(userMapper, redisTemplate, smsClient); } Test public void register_success_basicData() { when(userMapper.findByUsername(zhangsan)).thenReturn(null); when(userMapper.insert(any(User.class))).thenAnswer(invocation - { User user invocation.getArgument(0); user.setId(1L); return 1; }); ValueOperationsString, String valueOperations mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(valueOperations); RegisterRequest request new RegisterRequest(zhangsan, 13800000000); UserDto dto userService.register(request); assertNotNull(dto); assertEquals(Long.valueOf(1L), dto.getId()); assertEquals(zhangsan, dto.getUsername()); assertEquals(13800000000, dto.getPhone()); verify(userMapper).insert(any(User.class)); verify(valueOperations).set(user:1, zhangsan); verify(smsClient).sendWelcome(13800000000); } }第二个场景是空用户名。这里没有依赖交互只需要断言参数校验异常即可。Test public void register_blankUsername_throwIllegalArgument() { RegisterRequest request new RegisterRequest( , 13800000000); assertThrows(IllegalArgumentException.class, () - userService.register(request)); verify(userMapper, never()).insert(any(User.class)); }第三个场景是用户名已存在需要确保不执行后续插入和短信发送。Test public void register_existingUsername_throwBizException() { when(userMapper.findByUsername(zhangsan)) .thenReturn(buildUser(1L, zhangsan)); RegisterRequest request new RegisterRequest(zhangsan, 13800000000); BizException ex assertThrows(BizException.class, () - userService.register(request)); assertEquals(USER_EXIST, ex.getCode()); verify(userMapper, never()).insert(any(User.class)); verify(smsClient, never()).sendWelcome(anyString()); }第四个场景是短信发送异常。用户数据能查到不存在insert能成功但smsClient抛异常最终业务层应该抛出AFTER_HOOK_ERROR。这里还要注意redisTemplate.opsForValue()返回的ValueOperations最好只Mock一次避免测试里出现奇怪的NPE。Test public void register_smsException_wrapBizException() { when(userMapper.findByUsername(zhangsan)).thenReturn(null); when(userMapper.insert(any(User.class))).thenAnswer(invocation - { User user invocation.getArgument(0); user.setId(1L); return 1; }); ValueOperationsString, String valueOperations mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(valueOperations); doThrow(new RuntimeException(sms connect fail)) .when(smsClient).sendWelcome(anyString()); RegisterRequest request new RegisterRequest(zhangsan, 13800000000); BizException ex assertThrows(BizException.class, () - userService.register(request)); assertEquals(AFTER_HOOK_ERROR, ex.getCode()); }这四个测试覆盖了正常路径、非法输入、业务冲突、依赖异常。跑起来应该全部通过。代码量不多但每一条都锁定了业务行为后续别人改动代码只要有行为被破坏测试立刻会亮红灯。5.3 如何跑通并修复问题如果你照着上面代码写大概率会遇到一个问题报UnnecessaryStubbingException。这是因为MockitoJUnitRunner严格模式下会检查测试方法里所有Stub是否真的被调用到。比如register_success方法里配置了userMapper.findByUsername(zhangsan)但某个场景下业务提前返回没有走到这行Stub就会报错。解决方式有两种一是使用Silent模式二是把这些“可能没用到”的Stub配置放到各自独立的测试方法里。另一个常见问题是Mockito运行时报InvalidUseOfMatchersException。这种错多半是混合使用了参数匹配器和裸值。比如when(userMapper.findByUsername(eq(zhangsan), anyString())); // 错误参数数量都不对Mockito要求一个方法的所有参数要么全部用匹配器要么全部用裸值不能在同一个方法里混着来。多参数方法尤其容易踩坑我会把参数匹配器单独抽出来写保持风格统一。还有一个小技巧如果测试方法里需要verify同一个Mock的多个方法可以用InOrder来验证调用顺序尤其适合流程型业务。比如producer.send()必须在orderMapper.update()之后调用做不到这点就用InOrder。业务层单测能锁定顺序的尽量锁定避免重构时悄悄改变语义。6. 高频问题排查与经验速查6.1 Mock对象没有生效还在调真实依赖这是最常被问到的问题表现是测试跑着跑着突然连接了数据库或者报出“Cannot invoke xxx because userMapper is null”。原因基本是InjectMocks没有把Mock注入成功。当被测类有多个同类型参数或者有多个构造器时Mockito的自动注入很容易失败。建议彻底放弃依赖InjectMocks的自动注入改用Before手动创建被测对象Before public void setUp() { userService new UserServiceImpl(userMapper, redisTemplate, smsClient); }这样写虽然多一行代码但逻辑完全透明。如果项目里约定所有Service都使用构造器注入这个写法几乎不会出错。6.2 私有方法、静态方法怎么测关于私有方法我的意见很直接不要直接测私有方法。业务层的私有方法通常是一个可提取的公共逻辑比如转账手续费计算、状态校验。如果它重要就把它提取到一个独立的策略类或工具类里然后对该类做单元测试如果它不重要就不需要单独测被公开方法覆盖到即可。强行用反射或PowerMock去测私有方法只会让测试变得更脆弱。静态方法同理。Mockito本身不支持mock静态方法需要借助PowerMock或者Mockito 3.4针对特定版本可以mockStatic但我不推荐为静态方法引入额外依赖。如果业务代码中出现了必须mock的静态方法通常是对象生命周期没有被正确管理。我更倾向于把静态调用包到一个可注入的非静态组件里让依赖关系显式化。6.3 参数匹配器用错过导致的偶发失败Mockito的参数匹配器使用有一些不易察觉的规则。比如when(userMapper.findByUsername(zhangsan)) .thenReturn(user);这里裸值“zhangsan”会触发equals匹配但如果User对象重写了equals而构建测试数据时某些字段不同就匹配不上了。此时最好用anyString()或eq()when(userMapper.findByUsername(anyString())).thenReturn(user);再比如连续Stub时有人会写when(userMapper.findById(anyLong())).thenReturn(user1).thenReturn(user2);这里没有问题但如果业务方法只调用了一次第二次Stub永远不会被消费严格模式下会报UnnecessaryStubbingException。遇到这种情况不要硬凑thenReturn链先搞清楚业务里到底调用了几次。6.4 测试数据污染与并行执行问题多个测试方法复用同一个Mock对象和同一个传入对象是最容易产生“偶发失败”的根源。比如你在测试A里给User对象的name设置了“zhangsan”测试B复用了同一个对象并修改了name结果测试B失败。解决方法是每个测试方法内部独立构造数据不要在类级别维护“全局测试实体”。另外Mock对象本身不是线程安全的。如果项目里配置了Junit的并行测试执行业务层单测容易出现诡异的“Mock方法调用计数不对”问题。个人建议业务层测试不要并行跑保持单线程串行执行稳定性优先。真需要并行提速优先考虑在多模块构建层做并行而不是在单模块测试用例上做并行。6.5 覆盖率与断言质量的平衡很多团队把“行覆盖率80%”作为硬性指标结果测试人员为了凑覆盖率给每个getter/setter都写断言给每个catch分支都Mock一个异常再断言“没有抛出”。这种测试数量不少但价值极低。我更看重“行为覆盖率”被测方法在核心分支上的行为是否被锁定。比如注册成功、用户名为空、用户名重复、短信异常这四个场景覆盖了业务的核心决策树比一行不漏地覆盖代码有用得多。写测试时应该多问自己如果未来有人把短信发送这段代码删掉我的测试会不会失败如果不会说明这条测试没有真正锁住行为。行覆盖率可以作为一种参考数据不建议做强制KPI。一个好的业务层测试读起来像一部简短的需求文档输入是什么、依赖返回什么、期望抛什么异常、应该调用哪些合作者。关联到代码评审里这样的测试能让reviewer一眼看懂业务规则。把七步法用熟之后我发现写单测最消耗时间的往往不是代码本身而是想清楚“这个方法到底有哪些行为需要被锁定”。所以我后来习惯在写业务代码之前先列几个测试场景再动手写实现。这样代码自然就变得容易测试测试也反过来帮我把设计捋清了。最后再分享一个小技巧如果某个业务的测试写起来特别痛苦往往不是测试的问题而是业务类承担了太多职责。趁早拆分比硬生生凑覆盖率舒服得多。
返回列表