有时候不是我们不想做单元测试, 而是这代码写的实在是没法测试....
举个例子, 如果一辆汽车在产出后没完成测试, 那么没人敢去驾驶它. 代码也是一样的, 如果项目未能进行该做的测试, 那么客户就不敢去使用它, 即使使用了也会遇到“车祸”.
第1部分: "缝".
为什么要测试/测试的好处
- 它可以尽早发现bug, 解决bug
- 它会节省开发和维护一个软件的总成本. 实际上我们在维护软件上付出的成本要远大于在开发时付出的成本. 开发的时候编写单元测试确实会增加一些成本, 但是从长远来看这些测试还是会从维护上降低软件的总成本.
- 它会促使开发者改进设计. 如果开发时先写测试或者同时写测试代码, 那么开发者会不得不仔细考虑要解决的问题, 所以会写出更好的设计, 而且无需考虑如何测试代码.
- 相当于自成文档. 因为所有的测试就是被开发软件所有期待的行为.
- 增强自信, 去除恐惧. 有时修改代码后我们就会担心这是否对现有的功能造成了破坏, 而如果单元测试覆盖了软件的重要功能的话, 那么只要测试都能通过, 那么就基本可以确信功能没被破坏.
测试从不同的角度看可以分成很多类. 我们首先应该保证好单元测试能够很好的进行, 只要单元测试能够很好的进行, 那么其它测试应该都可以很好的进行.
为什么要写易于测试的代码
再详细说一下:
在谈到软件测试的时候, 网上的文章经常举这个建造汽车的例子, 那我也拿汽车这个例子说明问题吧.
假设我们需要设计并生产一辆汽车, 可能会有两种方式:
第一种是把车设计成一个复杂的整体, 把所有需要的零件都焊到了一起, 也可以说它只有一个大零件, 就是汽车本身. 这样做的好处就是我们不必花那么多时间和精力去制作发动机, 轮胎, 车窗等等这些可替换的零件了. 这么去做是有可能把汽车的设计和生产成本降低的. 但是如果汽车被长期使用, 考虑到售后及维护, 那么成本肯定会非常高了.
如果汽车坏了, 我们无法检测是哪里出错, 因为它是一个整体, 无法对某部分进行隔离测试; 即使我们知道哪里有问题, 我们还是无法替换损坏的部分, 因为它还是一个整体...
第二种方式就是正确的方式, 我们使用可替换的零件进行设计生产, 这样就会方便测试和售后维护. 因为车里的每个零件都可以被替换, 也可以取出来单独进行测试. 如果汽车不能启动, 那么就对每个零件进行检查, 最后替换出问题的零件即可, 而无需像第一种方式那样把整个车扒开进行大修.
很明显, 正常的汽车厂商都是使用的第二种方式, 因为其具有可测试性和可维护性.
软件开发这个领域和设计汽车是很相似的, 可以像第一种方式一样开发软件, 也可以像第二种方式一样开发软件.
在现实中, 有太多的开发者使用了第一种方式, 把一大堆代码和功能都放到了一起. 而实际上开发者们应该采用第二种方式来进行代码的设计和编写, 即使在开发初期这可能会花掉更多的时间和精力.
有的时候不是开发者不想采取第二种方式, 而是花了很大力气却发现写出来的代码仍然不能很好的进行单元测试, 所以实际问题是不知道该如何写出易于测试的代码.
什么样的代码易于测试
还是汽车的例子, 如果我们怀疑汽车的电瓶坏了, 那么采用第一种方式创造的汽车就无法进行对它的“电瓶”进行单独检测, 因为是焊到一起的, 也没有可以用检测的插头等; 而采用第二种方式建造的汽车则可以把电瓶拿出来, 然后我们使用电压表等专用的仪器在隔离的情况下对其进行检测.
第二种方式之所以可以进行隔离测试是因为它采用的是可替换零件, 也就是零件可以拿下来.
用专业的术语说就是第二种方式里有缝(seam). 在软件里, 什么是缝(seam)? 缝就是你可以在程序里替换行为的地方, 而不需要在这个地方进行修改. 或者说就是可以让你的代码移除依赖项并创建出可用于隔离测试对象的地方.....我可能解释的不明白, 看图吧:
虚线就是缝.
由于有缝的存在, 所以我们可以进行隔离测试:
分别使用Test Fixture和Test double来替换调用类和依赖项.
而采用第一种方式的软件就无法把代码拆出来进行测试了, 因为无法替换依赖项, 无法接入到测试环境, 也就是说无法进行隔离测试了.
为什么代码会无法进行隔离测试呢
无法测试的代码有一些特点:
- new 关键字. 如果这部分代码里出现了new关键字, 也就是说在构造函数或方法内创造了外部资源或较复杂类型的实例, 那么测试就会很困难了. 而应该采用的做法是依赖注入.
- 静态方法/属性调用. 静态方法会为它的调用者和它被调用时所在的类创建很紧的耦合. 使用像Math.Min(), String.Join()这些方法时是没有题的, 但是如果使用DateTime.Now, Console.Write() 那就可能会出问题了. 这时候你可能就需要使用一个包装类了.
- 单立体 Singleton. Singleton的本质是共享状态. 但是为了隔离测试, 最好还是避免使用singleton. 如果确实需要使用它的话, 那么在测试的时候可以使用一个非Singleton的替身来进行测试, 当然, 通过依赖注入.
- 全局共享状态, 这个应该明白
- 引用第三方框架或外部资源. 一旦有这样的引用的话, 就无法进行隔离测试了. 我们需要做的就是对这些东西抽象化, 把细节忽略只关心特定条件下的特定结果.
如何产生缝隙
- 解藕依赖项. 在C#里, 我们通过对接口编程而不是对实现来编程来实现这个任务.
- 依赖注入. 主要是采用构造函数注入.
做到这两点, 那么我们就可以使用test double(测试替身)来代替依赖项并注入到被测试类使用, 从而进行隔离测试.
例子
下面就是一个难以测试的例子, 这个代码并不完美, 无法展示出不可测试代码所有的特点, 但是也包含了至少两个特点:
首先它的依赖项都是new出来的, 这些依赖项就有依赖于数据库的, 所以测试的话, 我们还需要知道数据库里面特定的数据内容..这样的结果就是测试很难完成.
其次这里用到了第三方的Mapper.Map()静态方法, 这个方法也许是经过测试的并且没有副作用的, 但是也有可能不是. 而且它造成了ProductControllerHard和Mapper类之间的紧耦合.
针对第一个问题, 我想都知道怎么去处理了, 就是使用接口. 我就不多介绍了.
针对第二个问题, 使用静态方法造成了紧耦合. 如果这个静态方法是我们自己写的方法, 我们可以对其重构, 变成实例方法. 但是如果它来自第三方库, 并且第三方库没有提供可以依赖注入使用的版本, 那么我们自己可以写一个包装类(wrapper)来包装该方法:
但是由于这个Mapper来自AutoMapper库, 这个库提供了IMapper接口, 所以使用IMapper进行依赖注入即可.
可测试的代码应该如下:
这是第2部分, 介绍的是如何避免在构建对象时写出不易测试的代码. 本文的概念性内容大部分都来自Misko Hevery的这篇博客文章.
构建
还是用上文里汽车的例子.
通常情况下, 我们是先去建造汽车, 组装好汽车后, 我们再去驾驶它.
软件开发也类似, 我们应该把对象构造完毕之后, 再去用它. 但是有时候, 开发者会在构造过程中添加一些程序逻辑. 这就相当于车还没造完, 我们就驾驶它去兜风了. 这样做是不太好的.
构造函数是类用来创建其实例对象的方法, 这里的代码是用来准备该对象的. 但有时开发者会在构造函数里做一些其它的工作, 例如构建依赖项, 执行初始化逻辑等等.
在构造函数(或者更大一点, 指构建的过程)里, 做这些额外的工作会让测试变得异常困难. 这是因为像初始化依赖项, 调用服务, 设置状态的逻辑等这些工作会把用于测试的"缝"弄丢. 导致无法进行mock.
总之在构造的过程中做太多的工作会妨碍测试.
危险信号
- 在构造函数/字段声明里出现new关键字
- 如果构造函数里需要创建依赖, 那么这就会为该类与依赖项之间创造了紧耦合. 这个之前提过, 所以需要注入依赖. 但是简单的值类型, 例如字符串, List, Dictionary等还是可以的.
- 在构造函数/字段声明里调用静态方法
- 静态方法不可以被mock, 也不能被注入.
- 构造函数出现流程控制逻辑代码
- 这样就很难对逻辑直接进行测试了. 我们只能分别使用不同的方式构造该对象, 测试并确认对象的状态. 而这个状态通常对直接测试是隐藏的. 实际上只要不是赋值代码, 就有可能是问题代码.
- 构造函数里出现非赋值代码
- 存在另外一个初始化函数 (也就是说构造函数走了完, 但是对象并没有被完全初始化)
如何解决问题?
- 不要在构造函数里创建依赖项, 应该注入它们. 然后在构造函数里把它们赋值给类的私有变量.
- 当需要构建对象图(一组有引用关系的对象), 也包括对象需要一些构建的参数等情况, 应该使用工厂, 建造者模式, 或者IoC容器的依赖注入等, 目的是把这些对象的构建工作分离出去.
- 避免在构造函数里写逻辑代码, 例如条件, 循环, 计算等等. 也不能把逻辑代码放在别的方法, 然后调用该方法...
总之就是要避免对象的构建和对象的行为混合到一起, 因为它们在一起就会很难进行测试.
最后还有一点, 首先你需要知道, 根据angular的创始人Misko Hevery所说:
对象的构造分两类, 一种是可注入的, 一种是可new的.
可注入的对象可以由其它的一堆可注入对象组成. 它们可以为 可new的 对象工作. 可注入的对象通常是实现了接口的service, 像什么IUnitOfWork, IRepository, IxxxService等等.
可new的对象就是对象图里的终点, 例如实体或者值对象(Value Object)等.
为了易于测试, 针对这两类构造, 有下列规则:
可注入的对象可以在构造函数请求(注入)其它的可以注入对象, 但是不能在构造函数请求可new的对象.
反过来, 可new的对象可以在构造函数请求其它的可new对象, 但是不能在构造函数请求可注入的对象.
例子
第一个例子
这是不对的, 构建的过程中直接new的话, 就会造成紧耦合, 也无法在测试中使用Test Double来代替它们了. 如果测试中不代替它们的话, 有些服务的开销可能会很大.
正确的写法是使用依赖注入:
第二个例子
该例中, UserController只需要UserService和LoggingService两个依赖项. 但是UserService又依赖于UserRepository.
但是这样写就不对了, 这会造成UserController和UserRepository间的紧耦合, 而且配置UserService也并不是UserController的责任.
正确的写法是:
而UserService也最好是注入依赖.
而如果UserService并不是在构造函数注入UserRepository的话:
那么Controller里就应该这样写:
不过最好还是使用构造函数注入的写法.
第三个例子
仔细的说, 该例有不止一处错误.
首先它有条件判断逻辑代码; 此外它还使用了ApplicationState.IsRunning这个静态变量(就是全局状态); 而且在构造函数里还做了UserService的配置工作, 这不是UserController的责任.
尽量要避免全局变量, 它无法进行隔离, 测试会遇到麻烦, 例如并行测试时其中一个测试改变了静态变量的值就可能导致另一个测试失败.
但是粗略的说, 该例可以说就是一个错误, 如何配置UserService并不是UserController的责任, 所以, 正确的做法是把UserService配置相关的代码移出去, 让它自己去管理吧:
第四个例子
该例子中, LoggingService的Log方法需要一个Area类型的对象, 它是一个值对象.
所以它的错误就是, 不应该把可new的对象注入到可注入的对象里. 这么做的话, 测试就不好做隔离了.
正确的做法应该是, 作为方法的参数传递进来:
第五个例子
如果出现类类似initalize()或类似意思的方法, 很有可能说明该对象的责任太多了.
修改它很简单, 让各自的类负责自己的内容即可. 去掉initialize()方法即可.
例子就举这些, 并不全, 详细请看Angular作者的博文.
测试/运行时如何建立对象
上面例子里的UserController就是我们需要使用的对象, 在运行时, 代码可能是这样的:
构建这个对象还是有点麻烦的, 它的类关系图如下:
所以测试的设置过程也会比较麻烦:
当然也可以不直接new, 而是使用mock. 总之都很麻烦.
使用工厂
所以我们可以使用Factory等模式, 把构建UserController的工作放到工厂里:
可以这样调用:
使用IoC容器
如果项目使用了IoC容器的话, 还可以使用类似下面的用法:
第3部分, 依赖项
迪米特法则 (Law of Demeter)
还是使用建造汽车的例子. 生产汽车的时候需要轮胎, 组装时需要什么型号的轮胎, 就请求该型号的轮胎, 然后相关人员会从库房把该型号的轮胎送到产线用于组装.
我相信很少有汽车厂会这样做: 生产汽车时, 汽车组装工拿着库房的钥匙, 自己去库房从各种各样的轮胎中找所需要的型号..
这就是违反迪米特法则的一个例子.
迪米特法则大概的意思是: "只访问你自己创建的对象, 或者作为参数传给你的对象. 不要通过其它对象间接的访问对象"
用一句话归纳迪米特法则就是: "只与直系朋友交谈, 不要和陌生人交谈".
注意: 迪米特法则其实并不算严格的法则, 它只是一个非常有益的指导性原则.
存在的问题
用代码形容上面的例子就是:
这违反了迪米特法则, 导致了以下问题:
- 造成了BenzCar和Warehouse以及MichelinTire之间的紧耦合, 而实际上BenzCar只需要MichelinTire.
- 测试时, 设置会很麻烦. 代码里Warehouse是直系朋友, MichelinTire是陌生人. 我们需要为Warehouse和MichelinTire同时设置测试替身.
- 真正需要的依赖项没有明确在构造函数里定义. 这里Warehouse相当于是一个容器, 测试时, 我们可能会不知道要为Warehouse里的哪个东西做测试替身.
危险信号
下列写法可能意味着您的代码违反了迪米特法则:
- 代码里有这样的调用: "warehouse.getTire.getMichelinTire", 有一连串的点".". 但是有时候这样做是可以的, 例如流畅(fluent)形式的建造者模式就可以, 因为fluent接口通常会返回对象本身, 然后再去使用该对象.
- 依赖于容器. 例如把 IocContainer作为依赖注入使用.
- 依赖项的名称为XxxContext, XxxContainer, XxxEnvironment, XxxManager, XxxServiceLocator.
- 测试时需要创建返回mocks的mock对象.
- 测试时的设置非常麻烦.
解决办法
解决办法就是遵从迪米特法则.
只注入我们直接需要的依赖项, 直接使用它们. 这样就会保证依赖项很明确, 测试的时候一眼就能看出依赖于哪些对象.
代码示例
例子一
下面这个违反了迪米特法则, 直接注入的是Warehouse, 而实际用到的却是MichelinTire:
正确的做法是, 注入直接使用的依赖项:
例子二
下面的代码也违反了迪米特法则, 它注入了一个容器类的对象:
这个ServiceLocator就相当于是一个容器. 这样用的话, 写测试的人可能根本无法知道需要使用容器里面的哪个对象.
你也许会说这样做灵活(我以前也经常这样做), 但是重构的时候, 这里很容易出错, 因为根本看不出来真正依赖的是哪个对象.
正确的做法还是应该注入直接需要的依赖项:
第4部分, 全局状态
全局状态
全局状态, 也可以叫做应用程序状态, 它是一组变量, 这些变量维护着应用程序的高级状态.
在程序里, 全局状态可能都存放在一个全局状态对象里, 例如ASP.NET里面的HttpContext; 或者它们可能是全局的变量, 这些全局变量在程序的任何地方都可以访问.
不管是如何实现的全局状态, 每个全局状态变量在内存里只有一个实例. 所以如果一个类里更新了全局变量的值, 那么另一个类访问该变量的时候它的值就是刚才被更新的值.
有些情况下, 使用全局状态确实有用; 但是如果使用不当, 则会对测试造成很大的影响.
全局状态对测试引起的问题
- 使用静态方法或全局变量访问全局状态的时候, 就引起了对全局状态的直接耦合. 这很不好.
- 这种耦合就导致很难对测试进行设置. 针对每个测试, 我们必须创建和设置好存储全局状态的对象. 或者把全局变量设定为所需的值.
- 因为每个全局状态变量在内存里只有一个实例, 那么我们就无法进行并行单元测试了. 如果我们为A测试设定了全局变量的值, 然后在测试A结束前开始测试B, 这时测试B修改了全局变量的值, 这时测试A就可能会失败, 因为它所期待的全局变量不是这个值.
- 上面的这种现象就叫做鬼魅般的超距作用(Spooky Action at a Distance). 而实际项目中确实经常发生这样的情况, 并行跑单元测试的时候偶尔会失败, 而单独去跑失败的测试时却一直成功. 这种耦合到全局状态的测试就不能再称为隔离测试了.
危险信号
- 全局变量
- 调用静态字段或调用拥有静态字段的类的静态方法. 但也仅限于该类的静态方法使用了该类的静态字段.
- 单例模式 (Singleton Pattern)
- 单元测试会随机的失败, 但是又没发现明确的原因.
解决办法
- 尽量使用本地(局部, 越窄越好)状态变量
- 如果第三方库使用了静态方法, 那么应该使用一个包装类来对该方法进行包装. 这个包装类还是要实现一个接口. 用它的时候注入该接口即可. 这样测试的时候就可以为包装类创建测试替身了, 并把全局状态解耦.
- 使用可依赖注入(IoC/DI)的单例体, 这种单例体是由IoC容器创建的.
例子
就举一个例子吧.
有这样一个获取当前登录用户权限的类, 它使用的是单例模式:
这个是典型的单例模式, 它会保证在程序中只返回一个实例, 这里就不多介绍了.
下面这个Service会调用上面这个Auth类:
Auth是单例模式的, 而且还调用了静态方法.
现在的状态是, OfficeService和Auth所包含的全局状态紧密的耦合到了一起.
如何解决问题
首先应该把单例模式去掉, Auth类只保留两个属性和一个方法:
然后在service里面应该注入IAuth接口并使用:
那么接下来就需要保证这个IAuth无论在程序中注入了多少次, 都是同一个实例.
这时就需要使用依赖注入(DI) 库了. 现在的DI库通常允许指定IoC容器中每对绑定服务的作用范围(Scope), 或叫做生命周期管理.
例如ASP.NET Core内置的IoC容器就内置了这种功能. 在ASP.NET Core 项目的Startup类里, 这样写就可以保证每次请求IAuth的时候只会得到同一个对象实例:
现在这个"单例"的工作是由IoC容器来负责了. 在其它地方正常的注入IAuth使用即可.
概念性内容和更多的例子请参考Angular创始的人这篇文章: http://misko.hevery.com/code-reviewers-guide/flaw-brittle-global-state-singletons/
第5部分, 单一职责
类做了太多的工作
例子, 某软件公司, 原有项目开发, 测试, 售前, 售后, 财务等员工. 后来由于公司没钱, 裁掉了测试, 让开发兼职; 过了段时间, 又裁掉了需求和售后, 还是由你这个开发来兼职; 再过了段时间, 又裁掉了财务和售前, 还是由你来兼职.
某天上班之后, 你刚想写代码, 就接到了客户来电, 说键盘不好用, 让你去给调试一下. 花了1个小时从客户那里调试回来又刚想写点代码, 某客户说发票没给, 你又去快递发票. 回来之后又想写代码, 又有客户来电话咨询你公司的XXX管理系统, 经过半个小时的讲解, 客户没兴趣. 这时已经到了中午, 你却发现你的本职工作一点都做.
在现实世界中, 给某个员工赋与很多冲突的角色或职责是很不明智的.
在软件开发里也是这样的, 在为一个类赋予太多的职责会引出很多维护和测试的问题.
单一职责
单一职责是面向对象软件设计的准则之一, 它说的是: "一个类只能因为一个原因去改变".
这就是说我们应该增加 因为相同原因而做出改变的东西 的内聚性, 而降低 由于不同原因而做出改变的东西 的耦合性.
这句话通常被描述为: "一个类或一个方法只应该做一件事情, 并且要把它做好".
如果一个类有了太多的职责, 那么职责间的交互就会埋藏于类里, 这样做就很难一次修改一个职责. 对于测试来说, 这些交互之间也没有明显的"缝隙".
依赖项的构建工作并不是目标类本身的职责, 这项工作应该和类本身的职责分开. 所以我们会使用依赖注入配置好的对象. 我们应该对类进行抽取, 让其成为单一职责的类.
引起的问题
如果一个类有多个职责, 那么在测试上它会有以下问题:
- 如果一个类/方法有太多的功能, 那么针对它的测试就会特别多, 很容易让人难于理解也很难维护.
- 测试的设置也会更加的麻烦.
- 由于有多个原因去修改该类, 那么它的测试类/方法就会修改的更加频繁.
危险信号
什么样的类/方法会违反单一职责呢?
- 如果你在描述该类功能的时候用到了"和", "或", "还", "并且"等词.
- 类或者方法的代码很多.
- 注入了太多的依赖项.
- 一个类改变的太频繁了也可能意味着这个类的职责可能不止一个.
解决方案
如果一个类有很多职责, 那么可以这样做:
- 识别出类里面各个独立的职责.
- 给每个职责贴上标签.
- 解耦, 把其它功能抽取到单独的类, 最后保证每个类都是单一职责.
例子
举一个很简单的典型例子:
这个类, 有4个依赖项, 不算特别多, 但是也不少. 它的名字在这里就是它的描述, 里面包含了"或"的意思. 在它的方法参数里, 有一个标识, 像这样会改变方法的高级行为的标识, 通常就意味着该方法会有不止一个职责. 而方法体里面, 我们可以看到它确实有两个职责, 分别是发送邮件和打电话给客户.
优化后
经过识别, 抽取后, 该类应该分成下面两个类:
EmailCommand:
CallCommand: