历史遗留代码不敢重构?
每次改代码都要回归所有逻辑?
提测被打回?
在近期的代码重构的过程中,遇到了各式各样的问题。比如调整代码顺序导致bug,取反操作逻辑丢失,参数校验逻辑被误改等。
上线前需要花大量时间进行测试和灰度验证。在此过程最大的感受就是:一切没有单测覆盖的重构都是裸奔。
经历了没有单测痛苦磨难,查阅很多资料和实战之后,于是就有了这篇文章,希望能给你的单测提供一些参考。
认识单测 What单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。
关于测试的名词还有很多,如集成测试,系统测试,验收测试。是在不同阶段,不同角色来共同保证系统质量的一种手段。
笔者在工作中经常遇到一些无效单测,通常是启动Spring容器,连接数据库,调用方法然后控制台输出结果。这些并不能算是单测。示例代码如下:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ApplicationLoader.class)
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testAddUser() {
AddUserRequest addUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
ResultDTO addResult = userService.addUser(addUserRequest);
System.out.println(addResult);
}
}
Why
在工作中很多代码是没有单测的,这些项目也能正常得运行。那么为什么要编写单测呢?
好的单测在能够提供我们代码交付质量的同时,减少bug发现和修复的成本,进而提高工作效率。至于单测能够让QA开心,则只是锦上添花。
提升工作效率,在工作中程序员的大多数时间都耗费在了测试阶段,编码往往可能只占一小部分。
尤其是在修改已有代码时候,不得不考虑增量代码是否会对原有逻辑带来冲击,以及修复bug之后是否引入的新的bug。
笔者就曾陷入如此困境,一下午时间都在重复着打包,部署,测试…,在改bug和写bug之间无限循环,有时也会因为一个低级bug抓心挠肝刚到后半夜。
所以长远来看,单测是能够有效提高工作效率的!
提升代码质量,可测试通常与软件的设计良好程序相关,难以测试的代码一般设计上都有问题。所以有效的单测会驱动开发者写出更高质量代码。
当然,单测带来最直接的收益就是能够减少bug率,虽然单测不能捕获所有bug,但是的确能够暴露出大多数bug。
节省成本,单测能够确保程序的底层逻辑单元的正确性,让问题能够在RD自测阶段暴露出来。bug越早发现,修复成本往往更低,带来的影响也会更小,所以bug应该尽早暴露。
如下图红色曲线所示,在不同阶段修复bug的成本差别是巨大的。
代码的作者最了解代码的目的、特点和实现的局限性。写单测没有比作者更适合的人选了,所以往往代码作者往往是第一责任人。
编写单测的时机,一般是 The sooner, the better(越早越好)。尽量不要将单测拖延到代码编写完之后,这样带来的收益可能不尽如人意。
TDD(Test-Driven Development)测试驱动开发,是一种软件开发过程中的应用方法,以其倡导先写测试程序,然后编码实现其功能得名。
测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。
当然TDD是一种理想的状态,由于种种原因,想要完全遵守TDD原则,是有一定难度的,毕竟PM的需求往往是可变的。
边开发边写单测,先写少量功能代码,紧接着写单测,重复这两个过程,直到完成功能代码开发。
其实这种方案跟第一种已经很接近,当功能代码开发完时,单测也差不多完成了。这种方案也是最常见和推荐的方式。
开发后再补单测,效果往往是最差的。首先,要考虑的是代码的可测性,已经完成的代码可能并不具备可测试性,毕竟写代码的时候可以任意发挥。
其次,补单测时容易顺着当前实现去写测试代码,而忽略实际需求的逻辑是什么,导致我们的单测是无效的。
Which究竟哪些方法需要进行单测?这个困扰笔者很久的一个问题!如上文所说,单测覆盖率当然是越高越好,不过我们在考虑ROI时难免会做出一些妥协。
接受不完美,对于历史代码,全覆盖往往是不现实的。我们可以根据方法优先级(如照成资损,影响业务主流程)针对性补全单测,保证现有逻辑能正常运行。
对于增量代码,笔者认为没有必要全部覆盖,一般根据被测方法是否有处理(业务)逻辑来决定。
比如常见的JavaWeb项目代码中,Controller层,DAO层以及其他仅涉及接口转发相关的方法,往往不需要单测覆盖。而业务逻辑层的各种Service则需要重点测试。
对于自定义的工具类,正则表达式等固定逻辑,也是必须要测试的。因为这部分逻辑一般都是公共且通用的,一旦逻辑错误会产生比较严重的影响。
How好的单测一定是能够自动执行并查执行结果的,也不应当对外部有依赖,单测的执行应当是完全自动化,并且无需部署,本地IDE就能运行。
在写单侧前,不妨参考以下前人总结好的First原则。
F—Fast:快速
在开发过程中通常需要随时执行测试用例;在发布流水线中执行也必须执行,常见的就是push代码后,或者打包时先执行测试用例;况且一个项目中往往有成百上千个测试用例。
所以为了保证开发和发布效率,快速执行是单测的重要原则。这就要求我们不要像集成测试一样依赖多个组件,确保单测在秒级甚至毫秒级执行完毕。
I—Isolated:隔离
隔离性也可以理解为独立性,好的单测是每个测试用例只关注一个逻辑单元或者代码分支,保证单一职责,这样能更清晰的暴露问题和定位问题。
每个单测之间不应该产生依赖,为了保证单测稳定可靠且便于维护,单测用例之间决不能互相调用,也不能依赖执行的先后次序。
数据资源隔离,测试时不要依赖和修改外部数据或文件等其他共享资源,做到测试前后共享资源数据一致。
Fake,Stub和Mock
我们的被测试代码存在的外部依赖的行为往往是不可预测的,我们需要将这些"变化"变得可控,根据职责不同,可以分为Fake,Stubs,Mock三种。
假数据(Fake), 一些针对当前场景构建的简化版的对象,这些对象作为数据源供我们使用,职责就像内存数据库一样。
比如在常见的三层架构中,业务逻辑层需要依赖数据访问层,当业务逻辑层开发完成后即使数据访问层没有开发完成,也能通过构建Fake数据的方式完成业务逻辑层的测试。
UserDO fakeUser = new UserDO("zhangsan", "zhangsan@163.com");
public UserVO getUser(Long userId) {
// do something
User user = fakeUser; // 测试阶段替换:User user = userDao.getById(userId);
// do something
}
Fake数据虽然可以测试逻辑,但是当数据访问层开发完毕后可能需要修改代码,将Fake数据替换为实际的方法调用来完成代码集成,显然这不是一种优雅的实现,于是便有了Stub。
桩代码(Stub)是用来代替真实代码的临时代码,是在测试环境对依赖接口的一种专门实现。
比如,UserService中调用了UseDao,为了对UserService中的函数进行测试,这时候需要构建一个UserDao接口的实现类UserDaoStub(返回Fake数据),这个临时代码就是所谓的桩代码。
public class UserDaoStub implements UserDao {
UserDO fakeUser = new UserDO();
{
fakeUser.setUserName("zhangsan");
fakeUser.setEmail("zhangsan@163.com");
LocalDateTime dateTime = LocalDateTime.of(2021, 7, 1, 12, 30, 0);
fakeUser.setCreateTime(dateTime);
fakeUser.setUpdateTime(dateTime);
}
@Override
public UserDO getById(Long id) {
if (Objects.isNull(id) || id 0) {
emailService.sendVerifyEmail(request.getEmail());
return ResultDTO.success(id);
}
return ResultDTO.internalError("添加用户失败,请稍后重试");
}
/**
* 校验添加用户参数
*/
private ResultDTO validateAddUserParam(AddUserRequest request) {
if (Objects.isNull(request)) {
return ResultDTO.paramError("添加用户参数不能为空");
}
if (StringUtils.isBlank(request.getUserName())) {
return ResultDTO.paramError("用户名不能为空");
}
if (!EmailValidator.validate(request.getEmail())) {
return ResultDTO.paramError("邮箱格式错误");
}
return ResultDTO.success();
}
}
基于Mockito的单测示例如下,需要注意的下面是纯java代码,没有对象显示调用的方法都是已经静态导入过的。
@RunWith(MockitoJUnitRunner.class)
public class UserServiceImplTest {
// Fake:需要提前构造的假数据
AddUserRequest fakeAddUserRequest;
// Mock: mock外部依赖
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserDao userDao;
@Mock
private EmailService emailService;
@Before
public void init() {
fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
when(userDao.insert(any())).thenReturn(1L);
when(emailService.sendVerifyEmail(anyString())).thenReturn(true);
}
@Test
public void testAddUser4NullParam() {
// GIVEN
fakeAddUserRequest = null;
// WHEN
ResultDTO addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "添加用户参数不能为空");
}
@Test
public void testAddUser4BadEmail() {
// GIVEN
fakeAddUserRequest.setEmail(null);
// WHEN
ResultDTO addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "邮箱格式错误");
}
@Test
public void testAddUser4BadUserName() {
// GIVEN
fakeAddUserRequest.setUserName(null);
// WHEN
ResultDTO addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "用户名不能为空");
}
@Test
public void testAddUser4DbError() {
// GIVEN
when(userDao.insert(any())).thenReturn(-1L);
// WHEN
ResultDTO addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "添加用户失败,请善后重试");
}
@Test
public void testAddUser4SendEmail() {
// GIVEN
// WHEN
ResultDTO addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertTrue(addResult.isSuccess());
verify(emailService, times(1)).sendVerifyEmail(any());
verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail());
}
}
正如上文提到的,Spock能够让代码更加精简,尤其是在代码逻辑分支比较多的场景下。下面是基于Spock的单测。
class UserServiceImplSpec extends Specification {
UserServiceImpl userService = new UserServiceImpl();
AddUserRequest fakeAddUserRequest;
def userDao = Mock(UserDao)
def emailService = Mock(EmailService)
def setup() {
// Fake数据创建
fakeAddUserRequest = new AddUserRequest(userName: "zhangsan", email: "zhangsan@163.com")
// 注入Mock对象
userService.userDao = userDao
userService.emailService = emailService
}
def "testAddUser4BadParam"() {
given:
if (Objects.isNull(userName) || Objects.is(email)) {
fakeAddUserRequest = null
} else {
fakeAddUserRequest.setUserName(userName)
fakeAddUserRequest.setEmail(email)
}
when:
def result = userService.addUser(fakeAddUserRequest)
then:
Objects.equals(result.getMsg(), resultMsg)
where:
userName | email | resultMsg
null | null | "添加用户参数不能为空"
"Java填坑笔记" | null | "邮箱格式错误"
null | "javaTKBJ@163.com" | "用户名不能为空"
}
def "testAddUser4DbError"() {
given:
_ * userDao.insert(_) >> -1L
when:
def result = userService.addUser(fakeAddUserRequest)
then:
Objects.equals(result.getMsg(), "添加用户失败,请稍后重试")
}
def "testAddUser4SendEmail"() {
given:
_ * userDao.insert() >> 1
when:
def result = userService.addUser(fakeAddUserRequest)
then:
result.isSuccess()
1 * emailService.sendVerifyEmail(fakeAddUserRequest.getEmail())
}
}
思考总结
在验证商业模式之前,时刻要想考虑投入产出比。时间和商业成本太高不利于产品快速推向市场,所以什么时候推广单测,需要更高阶的人决策。
测试不可能序错误,单测也不例外。单测只测试程序单元自身的功能。因此,它不能发现集成错误、性能、或者其他系统级别的问题。
单测能够提高代码质量,驱动代码设计,帮助我们更早发现问题,保障持续优化和重构,是工程师的一项必备技能。
下方资料对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴我走过了最艰难的路程,希望也能帮助到你~
凡事要趁早,特别是技术行业,一定要提升技术功底。技术成长的每一个阶段都会遇到一个与之匹配的、难以跨越的,技术瓶颈期!这个阶段没有一次能解决的神药,只有自己不断的积累、沉淀、破局,到最后的爆发。而这些知识可能最开始都是枯燥的,就像看了大A不会小a,看了小a又牵扯出小b,没办法只能一层层的扒,一层层的学。
关注我的微信公众号:【伤心的辣条】免费获取~
我的学习交流群:902061117 群里有技术大牛一起交流分享~
如果我的博客对你有帮助、如果你喜欢我的博客内容,请 “点赞” “评论” “收藏” 一键三连哦,我们下篇文章见!
好文推荐:那个准点下班的人,比我先升职了…
包装成1年工作经验的测试工程师,我给他的面试前的建议如下
面试官:工作三年,还来面初级测试?恐怕你的软件测试工程师的头衔要加双引号…
自动化测试大总结