点击关注上方蓝字,阅读更多干货~
写在前面
在软件的开发阶段中,测试是⼀个重要的组成部分,它贯穿了整个开发的生命周期。从软件的开发角度,会将测试分为几个阶段:单元测试、集成测试、系统测试、验收测试。测试伙伴在系统测试的阶段对软件开发进行跟进,而对应的开发人员则是从功能研发开始,逐渐完成单元测试、集成测试的阶段开发,确认功能全部实现后,再进行提测。
那么在整体的开发过程中,你会不会有这样的吐槽:繁重的任务开发已经占据了开发者的大部分时间,为什么还要写单元测试?本文内容从实际开发的角度来介绍一些单元测试的方法论跟思路,期望为大家带来参考,欢迎共同交流。
为什么要有单元测试
单元测试的目标是隔离程序部件并证明这些单个部件是正确的。⼀个单元测试提供了代码片段需要满足的严密的书面规约。因此,单元测试带来了一些益处。单元测试在软体开发过程的早期就能发现问题。
单元测试消除程序单元的不可靠,采用自底向上的测试路径。通过先测试程序部件再测试部件组装,使集成测试变得更加简单。
单元测试允许程序员在未来重构代码,并且确保模块依然工作正确(复合测试)。这个过程就是为所有函数和方法编写单元测试,一旦变更导致错误发生,借助于单元测试可以快速定位并修复错误。
单元测试提供了系统的⼀种文档记录。借助于查看单元测试提供的功能和单元测试中如何使用程序单元,开发人员可以直观的理解程序单元的基础API。
⸺ 维基百科《Unit testing》
根据上面的说明我们可以归纳单元测试的核心要点:
从最小粒度保证代码逻辑正确
通过这个核心要点,我们可以做哪些事情?
在开发过程中发现编码中的低级错误
保证重构功能正确,及时提供文档记录
针对核心业务、核心应用、核心模块,创建单元测试保证业务正确
通过单元测试提升开发效率
什么时候单元测试
在日常的开发中,很多人都是先把代码写完,然后再针对写好的代码一点一点补单元测试。在这种开发过程中,你是不是已经把单元测试变成了最后的一步工作,变成了为了应付差事而不得不做的事情?最关键的一点是,你写的这些代码可能写了几天,具体的代码细节你已经不清楚了,这个时候你编写的单元测试是否很好的提升了你的代码质量呢?
所以,如果想应用好单元测试,最好将开发单元测试的时间移动至开发之前(测试先行开发)。但是如果是一个复杂的需求,那么对应的单元测试要怎么写?
针对这种情况,最关键的点是结合实际业务做好代码的任务分解。而程序员对业务视角的忽略是一个普遍存在的问题,但也是程序员需要突破的地方。让程序员拥有业务视角,也是写好代码的要求。实际上,现在流行的DDD设计方法的核心就是让业务人员跟开发人员使用一样的通用语言。
单元测试怎么写
从上文的说明里面,其实我们可以对单元测试的有效性进行一个概括, 针对业务视角来设计测试场景和场景化的单元测试用例,才算得上是一个有效的单元测试。
更详细点来说,你可以理解为针对一个大业务场景下的各种分支,正常的业务流程大家都知道怎么解决,可是对一些异常的情况,可能会有场景覆盖不全面的情形。这样就会引申出来一个关键点,针对业务场景去找到更多的测试场景,并对业务场景的多种情况进行补充。
比如说,我们的产品要做一个用户的注册跟登陆的功能,那么针对这种场景,要如何做任务的分解和单元测试例子的覆盖?
假定我们的代码是三层架构,controller作为应用入口,service做业务处理,在repository层做数据存储。
基于这种场景,我们对任务进行进一步分解,分解成两个场景:
用户注册
用户登录
按照上方的流程图,我们将这个需求分解成了两个场景,一个是用户的注册,一个是用户的登录, 再细分一下对应的开发任务:
编写User对象
在UserRepo中编写save方法保存User对象
在UserService中编写register方法实现用户注册
在UserController中编写resigter方法调用UserService的register
在UserRepo中编写find方法,根据用户名获取User对象
在这种任务的分解模式下,我们可以看到UserService下面有两个方法:register和login。
针对用户注册的场景,根据上面的流程图我们获得了两个对应的测试用例:
1. 输入->用户不存在->注册成功(返回对应用户)
2. 输入->用户存在->注册失败(抛出异常)
在开发的过程中,我们在写UserService时,针对register方法就要思考该方法会如何进行测试, 并且怎样将对应的测试用例全部覆盖。
UserController
User register(string name,string password);
UserService
User register(Paramter("username","password"));
UserServiceTest
@Test
public void should_register_success() {
UserRepository repository = mock(UserRepository.class);
when(repository.save(any())).then(returnsFirstArg());
UserService service = new UserService(repository);
User item = service.register(new Parameter("name","password"));
//验证想要的数据结果。
assertThat(item.getName()).isEqualTo("name");
assertThat(item.getPassword()).isEqualTo("password");
}
@Test
public void should_register_failure() {
UserRepository repository = mock(UserRepository.class);
//在这⾥通过mock仓储层的返回。
when(repository.find(any())).thenReturn(new User());
UserService service = new UserService(repository);
//验证异常
assertException(ExistUserException.class)
User item = service.register(new Parameter("name","password"));
}
因为在需求设计的过程中,我们暂定了这两个测试用例。在上述两个测试用例中,通过mock来对repo的调用进行模拟,通过模拟repo层返回的方式来实现service单层的独立性,因此我们可以将注意力只放在service单独一层。
碍于篇幅,对用户注册的单元测试与代码场景不做额外说明。
在上面的流程中,我们通过任务分解实现了用户注册的单元测用例的编写。在复杂任务的编写中,我们也是一样的形式,每次聚焦一小块业务代码的实现,对一小块代码进行添加维护。这样也更容易划分出代码的具体业务细节与对应职责。通过代码任务分解,编写了针对具体业务行为的单元测试,实现了产品侧的功能需求在单元测试侧保持一致。
如果后续用户注册有了额外的业务场景,比如注册后就自动登陆,那么对应的单测则变成了下面的形式。
public void should_register_success_and_login() {
UserRepository repository = mock(UserRepository.class);
when(repository.save(any())).then(returnsFirstArg());
UserService service = new UserService(repository);
User item = service.register(new Parameter("name","password"));
//验证是否登陆⽤户接⼝⽅法。确保业务改动后的代码⼀致。
verify(repository).login(any());
assertThat(item.getName()).isEqualTo("name");
assertThat(item.getPassword()).isEqualTo("password");
}
单元测试与集成测试
首先我们要说明一下集成测试是什么?顾名思义,集成测试是把不同的组件组合到一起进行测试。在实际的开发过程中集成就是按照一条业务执行路径,把代码模块组合到一起,看看他们是不是很好的配合,是不是按照这条业务路径得到了我们想要的结果。
以上面的场景举例:
1. 输入->用户不存在->注册成功(返回对应用户)
2. 输入->用户存在->注册失败(抛出异常)
//⽤Transcational 保证在单元测试前后数据库⼀致。
//注意在这⾥单元测试的过程⽤了单元测试数据库。跟dev数据库分开。
//
class UserControllerFlowTest{
private UserController controller;
//运⽤这个直接创建数据库
private UserDao userDao;
public void should_register_success() {
//从服务流程的⼊⼝进⼊。对Controller,service ,repo 进⾏集成测试。
User item = controller.register("name","password")
assertThat(item.getName()).isEqualTo("name");
assertThat(item.getPassword()).isEqualTo("password");
}
public void should_register_failure() {
User user = new User("name","password"):
userDao.save(user);
//现在数据库⾥⾯新建⽤户。模拟⽤户存在。
assertException(ExistUserException.class)
//断⾔会出现对应的异常
User item = controller.register("name","password")
}
}
TDD测试驱动开发
上面说明的开发过程是不是跟TDD测试驱动开发流程很像?
但是上文讲述的流程,跟TDD的流程中还是有一定区别的。TDD的开发模式是红-绿-重构的流程,红表示新写了一个测试,测试还没有通过的状态;绿表示写了功能代码,测试通过的状态;而重构则是在完成基本功能之后,调整代码的过程。上文讲述的流程则只是先写测试,再完成代码功能,跟TDD相比差了一个更重要的环节:重构。也就是说,测试先行开发跟TDD 差了一个重构的环节(refactoring)。
功能完成之后
在大多数人的印象里,最重要的是完成功能,通过了单元测试,对应的所有工作也圆满完成了,这样对应的代码就不需要去做改动了。然而,这种想法就会导致一个问题,新增的代码可能会污染原来的代码,形成了一些坏味道(code smell),让你的代码逐渐腐坏。这其实是一个微小的过程,因为你在开发的时候只顾着实现那些功能,而忽略了代码本身的质量。从这个角度看,TDD的重构过程其实是将实现功能的代码调整分成了两个阶段,每个阶段只注重一件事。开发阶段注重功能实现,重构阶段注重高质量代码。通过单元测试来保证代码重构的正确性,将实现功能跟代码重构分离。
总结
本文作者
凯多,来自缦图互联网中心后端团队。
长按识别二维码
查看更多精彩
-----END-----