cover_image

单元测试二三事

凯多 缦图coder
2024年01月31日 05:44


图片

点击关注上方蓝字,阅读更多干货~


图片
















写在前面



















在软件的开发阶段中,测试是⼀个重要的组成部分,它贯穿了整个开发的生命周期。从软件的开发角度,会将测试分为几个阶段:单元测试、集成测试、系统测试、验收测试。测试伙伴在系统测试的阶段对软件开发进行跟进,而对应的开发人员则是从功能研发开始,逐渐完成单元测试、集成测试的阶段开发,确认功能全部实现后,再进行提测。

 

那么在整体的开发过程中,你会不会有这样的吐槽:繁重的任务开发已经占据了开发者的大部分时间,为什么还要写单元测试?本文内容从实际开发的角度来介绍一些单元测试的方法论跟思路,期望为大家带来参考,欢迎共同交流。




图片






为什么要有单元测试

图片


单元测试的目标是隔离程序部件并证明这些单个部件是正确的。⼀个单元测试提供了代码片段需要满足的严密的书面规约。因此,单元测试带来了一些益处。单元测试在软体开发过程的早期就能发现问题。

 

单元测试消除程序单元的不可靠,采用自底向上的测试路径。通过先测试程序部件再测试部件组装,使集成测试变得更加简单。

 

单元测试允许程序员在未来重构代码,并且确保模块依然工作正确(复合测试)。这个过程就是为所有函数和方法编写单元测试,一旦变更导致错误发生,借助于单元测试可以快速定位并修复错误。

 

单元测试提供了系统的⼀种文档记录。借助于查看单元测试提供的功能和单元测试中如何使用程序单元,开发人员可以直观的理解程序单元的基础API。

 ⸺ 维基百科《Unit testing》



  



根据上面的说明我们可以归纳单元测试的核心要点

  • 从最小粒度保证代码逻辑正确


通过这个核心要点,我们可以做哪些事情? 

  • 在开发过程中发现编码中的低级错误

  • 保证重构功能正确,及时提供文档记录

  • 针对核心业务、核心应用、核心模块,创建单元测试保证业务正确

  • 通过单元测试提升开发效率






什么时候单元测试

图片


在日常的开发中,很多人都是先把代码写完,然后再针对写好的代码一点一点补单元测试。在这种开发过程中,你是不是已经把单元测试变成了最后的一步工作,变成了为了应付差事而不得不做的事情?最关键的一点是,你写的这些代码可能写了几天,具体的代码细节你已经不清楚了,这个时候你编写的单元测试是否很好的提升了你的代码质量呢?


所以,如果想应用好单元测试,最好将开发单元测试的时间移动至开发之前(测试先行开发)。但是如果是一个复杂的需求,那么对应的单元测试要怎么写?


针对这种情况,最关键的点是结合实际业务做好代码的任务分解。而程序员对业务视角的忽略是一个普遍存在的问题,但也是程序员需要突破的地方。让程序员拥有业务视角,也是写好代码的要求。实际上,现在流行的DDD设计方法的核心就是让业务人员跟开发人员使用一样的通用语言。






单元测试怎么写

图片


从上文的说明里面,其实我们可以对单元测试的有效性进行一个概括, 针对业务视角来设计测试场景和场景化的单元测试用例,才算得上是一个有效的单元测试

 

更详细点来说,你可以理解为针对一个大业务场景下的各种分支,正常的业务流程大家都知道怎么解决,可是对一些异常的情况,可能会有场景覆盖不全面的情形。这样就会引申出来一个关键点,针对业务场景去找到更多的测试场景,并对业务场景的多种情况进行补充。


 比如说,我们的产品要做一个用户的注册跟登陆的功能,那么针对这种场景,要如何做任务的分解和单元测试例子的覆盖?


图片

假定我们的代码是三层架构,controller作为应用入口,service做业务处理,在repository层做数据存储。


基于这种场景,我们对任务进行进一步分解,分解成两个场景:


  • 用户注册

图片


  • 用户登录

图片


按照上方的流程图,我们将这个需求分解成了两个场景,一个是用户的注册,一个是用户的登录, 再细分一下对应的开发任务:

  • 编写User对象

  • 在UserRepo中编写save方法保存User对象

  • 在UserService中编写register方法实现用户注册

  • 在UserController中编写resigter方法调用UserService的register

  • 在UserRepo中编写find方法,根据用户名获取User对象

  • 在UserService中编写login方法,实现用户登录
  • 在UserController中编写login方法调用UserService 的login


在这种任务的分解模式下,我们可以看到UserService下面有两个方法:register和login


图片


针对用户注册的场景,根据上面的流程图我们获得了两个对应的测试用例:


1. 输入->用户不存在->注册成功(返回对应用户)

2. 输入->用户存在->注册失败(抛出异常)

 

在开发的过程中,我们在写UserService时,针对register方法就要思考该方法会如何进行测试, 并且怎样将对应的测试用例全部覆盖。


UserController

User register(string name,string password);

UserService

User register(Paramter("username","password"));

UserServiceTest

@Testpublic 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");}@Testpublic 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单独一层。


碍于篇幅,对用户注册的单元测试与代码场景不做额外说明。

 

在上面的流程中,我们通过任务分解实现了用户注册的单元测用例的编写。在复杂任务的编写中,我们也是一样的形式,每次聚焦一小块业务代码的实现,对一小块代码进行添加维护。这样也更容易划分出代码的具体业务细节与对应职责。通过代码任务分解,编写了针对具体业务行为的单元测试,实现了产品侧的功能需求在单元测试侧保持一致。


如果后续用户注册有了额外的业务场景,比如注册后就自动登陆,那么对应的单测则变成了下面的形式。

@Testpublic 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. 输入->用户存在->注册失败(抛出异常)

@Transactional//⽤Transcational 保证在单元测试前后数据库⼀致。//注意在这⾥单元测试的过程⽤了单元测试数据库。跟dev数据库分开。//class UserControllerFlowTest{
@Autowired private UserController controller;
@Autowired //运⽤这个直接创建数据库 private UserDao userDao; @Test public void should_register_success() { //从服务流程的⼊⼝进⼊。对Controller,service ,repo 进⾏集成测试。 User item = controller.register("name","password") assertThat(item.getName()).isEqualTo("name"); assertThat(item.getPassword()).isEqualTo("password"); }
@Test 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的重构过程其实是将实现功能的代码调整分成了两个阶段,每个阶段只注重一件事。开发阶段注重功能实现,重构阶段注重高质量代码。通过单元测试来保证代码重构的正确性,将实现功能跟代码重构分离。















总结



















一个没有单元测试或者覆盖率低的工程,其代码质量和稳定性,以及对应服务的可用性肯定得不到对应的保障。通过编写高质量的单元测试代码,将TDD的思维运用到业务开发过程中,能有效的提升代码质量与服务稳定性。同时可以提升研发的任务分解能力、开发水平,并且也可以提升项目开发过程中程序的自测与提测质量。



图片



本文作者


凯多,来自缦图互联网中心后端团队。





图片

长按识别二维码

查看更多精彩

图片


-----END-----




继续滑动看下一个
缦图coder
向上滑动看下一个