前后端分离开发模式下后端质量的保证 —— 单元测试
概述
在今天, 前后端分离已经是首选的一个开发模式。这对于后端团队来说其实是一个好消息,减轻任务并且更专注。在测试方面,就更加依赖于单元测试对于API以及后端业务逻辑的较验。当然单元测试并非在前后端分离流行之后才有,它很早就存在,只是鲜有人重视且真的能够用好它。而在前后端分离开发模式下,特别是两者交付时间差别很大的情况时,后端可能需要更加地依赖于单元测试来保证代码的正确性。
本文主要围绕单元测试展开,从单元测试的基础概念说起,对比单元测试和集成测试,同时我们还会聊一聊单元测试与测试驱动开发的区别。在我们了解完单元测试的概念之后,我们会探讨一下什么样的单元测试算得上是好的单元测试,它们具备哪些特征,如何使用隔离框架来帮助我们对一些复杂的组件进行测试。最后一个内容也是本文想要阐述的重点: 单元测试是开发人员写的,那么开发人员在写自己的代码的时候,如何提高自己代码的可测试性? 什么样的代码算的上是对单元测试友好的代码? 带着这些问题,我们这就来开始我们的单元测试之旅。
目录
什么是单元测试?
有人可能写过单元测试,但是却不知道为什么要写单元测试,有人知道为什么要写单元测试,但不确定如何写才是好的单元测试。但是对于“测试” 我们每个人都轻车熟路, 你看看下面的功能是否似曾相识?
单元测试与测试
测试种类分为很多种:单元测试、集成测试、系统测试、压力测试、负载测试、验收测试等等 ,我们今天不打算也不能进行系统性的介绍。作为开发人员,我们平常所说的“测试”。也就是说你代码写完了,老大问你测试通过了吗?你说过了,然后就可以Check in 代码了。这里的“测试”,实际上指的是不完整的功能测试。为什么说它不完整,是因为从专业测试的角度来讲,还需要定义规范的测试用例,用例写完之后还要开发和测试人员一起评审等等 。 而我们只是在脑海中预想了一下它应该如何工作的,应该给我什么结果等,然后运行一下,咦,还真是这样的,那我们的测试就算通过了。 会有多少Bug,就取决于我们这个预想有多细了,往往有时候我们只能想到很少一部份,这时候专业独立的测试人员就派上用场了。同时精通开发和测试的人是很有优势的,自己能够保证写出来的软件的质量,这也是现代敏捷开发团队所追求的,但是这样的人总是少之又少。
单元测试是通过把一个应用程序拆分成可测试的足够小的部分,然后把每一部分与其它所有功能隔离开,单独对这一部分进行测试。而这个“可测试的足够小的部分”就称之为“单元“,在C语言中一个单元可以是一个函数,在C#中单元测试可以是一个类。 如果所有的单元都能够像我们所预料的正常工作,那么把他们合并起来就能够保证至少不会出现很严重的错误。
单元测试与集成测试
为什么要把这两项拿出来对比,是因为这两项很容易混淆,一不小心你就可能把单元测试写成集成测试了,这也是为什么单元测试有时候看起来那么糟糕的主要原因。我们上面说单元测试是把每一个单元孤立出来,在测试的时候不能和任何其它的单元有任何联系,这是单元测试,反过来你一旦在你的测试代码中引入了另外一个单元,那你就要开始小心,你是不是已经开始写集成测试了。 当然有时候往往不是引入了其它的一些单元,有可能是一些组件,下面列出了一些单元测试和集成测试的主要特点,希望能够帮助大家区分单元测试与集成测试。
单元测试
- 可重复运行的
- 持续长期有效,并且返回一致的结果
- 在内存中运行,没有外部依赖组件(比如说真实的数据库,真实的文件存储等)
- 快速返回结果
- 一个测试方法只测试一个问题
集成测试
- 利用真实的外部依赖(采用真实的数据库,外部的Web Service,文件存储系统等)
- 在一个测试里面可能会多个问题(数据库正常确,配置,系统逻辑等)
- 可以在运行较长时间之后才返回测试结果
单元测试与测试驱动开发(TDD)
测试驱动开发其实我们用一个问题就可以解释清楚,那就是“你什么时候写单元测试?” 有人选择在开发的代码写完之后再写,这样我们的开发过程是: 理解需求-》编写代码-》针对代码结合需求写单元测试。后来大家发现,往往在写单元测试的时候发现自己有些需求没有理解清楚,或者这些需求原来设计的时候就没有考虑到,所以又重新改原来的代码。 于是有人就说,为什么我们不干脆反过来? 先写单元测试,再写代码? 所以我们开发的过程就变成了这样:理解需求-》针对需求写单元测试 -》 编写代码让单元测试通过。 最开始是叫测试先行(TFD: Test First Development) ,后来就发展成我们熟知的"测试驱动开发"了。
测试驱动开发最大的好处是,让开发人员更好的理解需求,甚至是挖掘需求之后再进行开发。 当然,我们不可能一次性把所有的测试代码都写出来之后再写代码,这是一个重复迭代的过程:
由于TDD不是我们本篇的主要内容,这里仅仅希望能给大家一个对TDD的浅显认识的同时了解到TDD与单元测试的联系。到这里,我们对于单元测试的概念就介绍的差不多了,接下来是代码时间。:) 我们来上一个真实的例子更形象的了解一下单元测试。
一个单元测试的例子
那么问题来了,我们用什么来案例来写了一个单元测试的例子呢?既然这样,那么我们就用前两篇我们在领域模型驱动设计中讲到的用户注册的例子吧。在用户的领域服务中,UserService提供了一个Register的方法,通过用户名、邮箱和密码三个参数来创建一个用户的对象。 像所有注册逻辑一样,邮箱是不能重复的,这是我们现在这个领域服务中比较重要的业务逻辑,所以我们的单元测试必须要覆盖到。 我们的测试
// UserServiceTests.cs
1 namespace RepositoryAndEf.Domain.Tests 2 { 3 public class UserServiceTests 4 { 5 private IRepository<User> _userRepository = new MockRepository<User>(); 6 7 [Fact] 8 public void RegisterUser_ExpectedParameters_Success() 9 { 10 var userService = new UserService(_userRepository); 11 var registeredUser = userService.Register( 12 "hellojesseliu@outlook.com", 13 "Jesse", 14 "Jesse"); 15 16 var userFromRepository = _userRepository.GetById(registeredUser.Id); 17 18 userFromRepository.Should().NotBe(null); 19 userFromRepository.Email.Should().Be("hellojesseliu@outlook.com"); 20 userFromRepository.Name.Should().Be("Jesse"); 21 userFromRepository.Password.Should().Be("Jesse"); 22 } 23 24 [Fact] 25 public void RegisterUser_ExistedEmail_ThrowException() 26 { 27 var userService = new UserService(_userRepository); 28 var registeredUser = userService.Register( 29 "hellojesseliu@outlook.com", 30 "Jesse", 31 "Jesse"); 32 33 var userFromRepository = _userRepository.GetById(registeredUser.Id); 34 userFromRepository.Should().NotBe(null); 35 36 Action action = () => userService.Register( 37 "hellojesseliu@outlook.com", 38 "Jesse_01", 39 "Jesse"); 40 action.ShouldThrow<ArgumentException>(); 41 } 42 43 public void RegisterUser_ExistedName_ThrowException() 44 { 45 var userService = new UserService(_userRepository); 46 var registeredUser = userService.Register( 47 "hellojesseliu@outlook.com", 48 "Jesse", 49 "Jesse"); 50 51 var userFromRepository = _userRepository.GetById(registeredUser.Id); 52 userFromRepository.Should().NotBe(null); 53 54 Action action = () => userService.Register( 55 "hellojesseliu_02@outlook.com", 56 "Jesse", 57 "Jesse"); 58 action.ShouldThrow<ArgumentException>(); 59 } 60 61 } 62 }
在这个例子中我们用到了 Fluentassertions、XUnit这两个开源组件。另外Moq作为一个不错的单元测试Mock框架也推荐给大家。
- Fluentassertions:相对于.NET测试工具本身提供的Assert,Fluentassertions提供基于链式构建的一些更人性、易懂的方法来帮助写出更好理解的单元测试代码 。 上面代码中我们所用到的ShoudBe、NotBe、以及ShoudThrow等方法即来自于Fluentassertions,还有更多方法可以到官方文档上查询。
- Xunit:这是一个开源的单元测试工具
- Moq:为了让单元测试可以完全脱离外部组件,我们需要用到一些Mock对象和Stub对象,而Moq是一个开源的Mock类框架可以帮助我们实现这些功能 。我们上面代码中用到的MockRepository是我们自己用List封装的一个IRepository实例,支持增删改查,相当于我们把数据持久化于内存中。
1 namespace RepositoryAndEf.Data 2 { 3 public class MockRepository<T> : IRepository<T> where T : BaseEntity 4 { 5 private List<T> _list = new List<T>(); 6 7 public T GetById(Guid id) 8 { 9 return _list.FirstOrDefault(e => e.Id == id); 10 } 11 12 public IEnumerable<T> Get(Expression<Func<T, bool>> predicate) 13 { 14 return _list.Where(predicate.Compile()); 15 } 16 17 public bool Insert(T entity) 18 { 19 if (GetById(entity.Id) != null) 20 { 21 throw new InvalidCastException("The id has already existed"); 22 } 23 24 _list.Add(entity); 25 return true; 26 } 27 28 public bool Update(T entity) 29 { 30 var existingEntity = GetById(entity.Id); 31 if (existingEntity == null) 32 { 33 throw new InvalidCastException("Cannot find the entity."); 34 } 35 36 existingEntity = entity; 37 return true; 38 } 39 40 public bool Delete(T entity) 41 { 42 var existingEntity = GetById(entity.Id); 43 if (existingEntity == null) 44 { 45 throw new InvalidCastException("Cannot find the entity."); 46 } 47 48 _list.Remove(entity); 49 return true; 50 } 51 } 52 }
我们也可以用Moq框架在单元测试中临时初始化一个MockRepository
1 private readonly IRepository<User> _userRepository; 2 private List<User> _userList = new List<User>(); 3 public UserServiceTests() 4 { 5 var mockRepository = new Mock<IRepository<User>>(); 6 7 // 初始化新增方法 8 mockRepository.Setup(r => r.Insert(It.IsAny<User>())).Returns((User user) => 9 { 10 if (_userList.Any(u => u.Id == user.Id)) 11 { 12 throw new InvalidCastException("The id has already existed"); 13 } 14 15 _userList.Add(user); 16 return true; 17 }); 18 19 _userRepository = mockRepository.Object; 20 }
在单元测试代码中临时初始化Mock repository
- 更灵活:可以只初始化用到的方法
- 更强的控制能力:可以从外部(单元测试代码内)定义所有的行为
- 多态性:与其它单元测试类隔离,可以有不同的行为
Mock和Stub的区别
因为有很多测试框架把Mock和Stub区别对待,初学者也会对这两个概念表示含糊不清。简单的来说,Mock与 Stub最大的区别是:
Stub主要用来隔离其它的组件让单元测试可以正常的进行,我们不会对Stub来进行Assert。
Mock则用来和测试代码进行交互,可以说我们会针对Mock来写测试代码,也会对它进行 Assert来验证我们的代码。
在我们上面的代码中,我们只用到了一个Mock(MockRepository),如果同样是用户注册的业务,有哪些地方是我们可能需要用到Stub的? 试想一下现实的注册场景,如果用户注册成功了, 我们是不是需要给用户发送注册成功的邮件通知?这里有一点需要注意的是,注册用户相关的代码属于我们领域服务的职责,但是注册成功发送邮件、发送短信、甚至你要干一些系统相关的初始化操作都是属于应用层的事情。关于这点,大家还可以回顾之前的两篇关于DDD的文章。如果我们针对应用层的代码编写单元测试,那么我们就需要把一些组件比如邮件、日志等用Stub隔离掉,来保证测试代码的运行。
怎样才算好的单元测试?
什么是一个好的单元测试?
- 是自动化的和可重复运行的
- 很容易实现
- 持续有用
- 任何人只要轻松的点一下按钮就可以运行
- 运行不会花太长的时间
- 一直返回同样的结果(如果你不改变任何代码或参数)
- 单元测试是完全隔离的,不应该有任何其它的依赖
- 当单元测试失败的时候,应该一眼就看出是因为什么原因导致的这个失败
- 一个测试方法只验证一个case,只用一个Mock,Stub可以是多个
- 好的命名,最好是可以从方法名看出以下三个要素(所以一般我们采用三段命名法):
- 测试目标
- 条件
- 应该得到的结果
想知道你写的单元测试是不是好的单元测试么?
- 2个星期,或者2个月甚至2年前写的单元测试还能运行并且得到同样的结果么?
- 团队中的其它人也可以运行你2个月前写的单元测试么?
- 可以点击一下按钮就运行你所有的单元测试,并返回正确的结果么?
- 所有的单元测试可以在几分钟之内完成么?
测试用例都有哪些?
写单元测试的代码可能是开发的好几倍,这句话是真的!在于你的单元测试用例覆盖的有多广,比如说我们上面针对用户注册这一个业务场景写了3个测试用例,其实是远远不够的。
非预期的用例
不管我们上面那个完全成功注册的用例,还是另外两个由于邮箱和名称重复而没有注册成功的用例。这三个用户都是预期的,如果是非预期的,比如:
- 如果邮箱地址不是一个正确格式的邮箱?
- 如果我邮箱不填?用户名不填?
边界测试
- 如果我的邮箱名称或者用户名长度超过最大限制?
回归测试
修改bug是一件难过的事情,在复杂且耦合度很高的系统下修改bug是一件难过且胆破心惊的事情,那么你感受一下:在复杂且耦合度很高的系统下不断的修改同一个bug会是一种什么样的心情。我们后期维护代码的时候对于新增的改动也需要加上对应的测试代码来保证单元测试的完整性。
自动化——持续集成
持续集成里面已经包含了单元测试的自动化。它倡导团队开发成员必须经常集成他们的工作,甚至每天都可能发生多次集成。而每次的集成都是通过自动化的构建来验证,包括自动编译、发布和测试,从而尽快地发现集成错误,让团队能够更快的开发内聚的软件。感兴趣的同学可以自行了解,这是一个关于DevOps的话题,就不在本文作过多的表述。光想象一下那种不管谁有代码check in都引发所有单元测试代码的自动运行,在单元测试覆盖的全的情况下基本可以过滤掉很多的潜在bug。
提高代码的可测试性
我们多数遇到的项目之所有很少看到单元测试的代码大概是因为以下的几个原因:
- 领导不重视 ,团队内没有这个风气
- 项目太紧,根本不给时间(可能也有领导不重视的原因)
- 开发人员对于单元测试不熟悉 ,不知道怎么样写好单测试。(不好的单元测试代码,写了可能等于白写,因为根本没人去运行它们)
- 解决方案里面的业务层根本没有办法写单元测试(耦合度太高,重依赖,这是当我排除前面3个困难之后,常常遇到的最后一道坎)
关于最后一点是需要架构师、或者比较有经验在开发者在最开始设计系统结构的时候需要考虑到的。如果最开始没有考虑到怎么办? 那太好了,因为很多项目最开始都没有考虑到,所以我们的单元测试代码总是盛行不起来。(可怜这一层面的架构师也是少之又少,倒是有很多架构师活跃于各大论坛讲高并发、各种分布式组件,能挽起袖子去重构/优化代码结构的人真的少之又少。因为实在太累,而且搞不好还容易出错,属于最有挑战,但其实却往往不被老板重视的一项苦差事)遇到比较多的问题(包括BAT级别的项目,可能外面的架子、整体架构图画出来那是非常的漂亮,但是一旦涉及到业务层面的代码....后面我就不说了。)
整体架构层面的考虑
如果我们现在是重新开始搭建一套系统,那我们可以怎样开始?或者说如果我们有魄力和决心去重构一套系统,我们该往哪些方向去走?—— 从DDD的分层架构说起
分层: 首先是通过分层把业务与其它基础组件隔离开,不要让一些发邮件、记日志、写文件等这些基础组件混合了我们的业务,在应用层将领域业务与这些为应用服务的基础功能组合起来。在之前的一篇文章 《初探领域驱动设计——为复杂业务而生》有具体的介绍。
领域业务层无依赖
在洋葱架构中,核心(Core)层是与领域或技术无关的基础构件块,它包含了一些通用的构件块,例如list、case类或Actor等等。核心层不包含任何技术层面的概念,例如REST或数据库等等。
如果有依赖,请依赖于接口抽象,而非具体的实现,比如我们例子中的IRepository。这些架构思想其实已经很老很老了,但是我们多数的项目还停留在更更老的三层架构思想上,说好的技术极客们都去哪里了?
保持类的引用/依赖关系清晰,可注入
不要使用静态方案
且不要说一些面向对象的特性没有办法使用到,一旦开了这个口子。天知道你的代码里面会依赖于多少个外部静态方法,并且完全没有办法在测试代码中将它们mock掉,万一你在静态方法里面又有其它依赖,那对于单元测试来说就是一场终结。
保持一个类所有的外部引用易见
1. 所有外部引用易见
2. 外部引用可注入/替换
除了构造函数注入以外,我们还可以采用构造函数注入、字段、以及方法注入的方式,将我们的方法替换掉。这种方式不仅仅是对单元测试友好,更是一种良好的代码组织方式,是可能提供代码的易读性,以及可维护性的。要知道代码主要是给人阅读的,只是偶尔让机器执行一下。如果有跳槽经验的同学应该都有过那种到了一个公司,有一个很复杂的系统,但是没有任何的文档(稍微好一点的可能会有表字典)的感受,唯一了解系统业务的方式是play with the system 然后,看代码。 对于种无法一眼看到各个类之间的关系的代码,特别是一个类里面有好几百个方法、上万行代码的时候, 虽然我对于干这种事情已经轻车熟路,但当时的心情难免还是有些激(操)动(蛋)的。
依赖于接口/抽象,而非实现
这点我想也就不需要细述了,在单元测试这个场景里面。我们主要是将业务与非业务相关功能用接口隔离开,那么我们在单元测试中就可以很灵活的用Mock或者Stub来替换。比如:读写文件、访问数据库、远程请求等等。
最后
编写单元测试虽然简单,但是考验的却是细心和对业务的理解程度。而且往往写单元测试代码所花的时间比写功能代码还要多,在任务时间进度紧、又不受重视的情况下,自己很少有人会主动愿意去写。但是,好的单元测试代码确实在长期能够体现出它的价值。