NET单元测试的艺术

开篇:上一篇我们学习基本的单元测试基础知识和入门实例。但是,如果我们要测试的方法依赖于一个外部资源,如文件系统、数据库、Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需要创建测试存根伪对象模拟对象。这一篇中我们会开始接触这些核心技术,借助存根破除依赖,使用模拟对象进行交互测试,使用隔离框架支持适应未来和可用性的功能。

系列目录:

1.入门

2.核心技术

3.测试代码

4.设计和流程

一、破除依赖-存根

1.1 为何使用存根?

  当我们要测试的对象依赖另一个你无法控制(或者还未实现)的对象,这个对象可能是Web服务、系统时间、线程调度或者很多其他东西。

  那么重要的问题来了:你的测试代码不能控制这个依赖的对象向你的代码返回什么值,也不能控制它的行为(例如你想摸你一个异常)。

  因此,这种情况下你可以使用存根

1.2 存根简介

  (1)外部依赖项

一个外部依赖项是系统中的一个对象,被测试代码与这个对象发生交互,但你不能控制这个对象。(常见的外部依赖项包括:文件系统、线程、内存以及时间等)

  (2)存根

一个存根(Stub)是对系统中存在的一个依赖项(或者协作者)的可控制的替代物。通过使用存根,你在测试代码时无需直接处理这个依赖项。

1.3 发现项目中的外部依赖

  继续上一篇中的LogAn案例,假设我们的IsValidLogFilename方法会首先读取配置文件,如果配置文件说支持这个扩展名,就返回true:

    public bool IsValidLogFileName(string fileName)
{
// 读取配置文件
// 如果配置文件说支持这个扩展名,则返回true
}

  那么问题来了:一旦测试依赖于文件系统,我们进行的就是集成测试,会带来所有与集成测试相关的问题—运行速度较慢,需要配置,一次测试多个内容等。

  换句话说,尽管代码本身的逻辑是完全正确的,但是这种依赖可能导致测试失败。

1.4 避免项目中的直接依赖

  想要破除直接依赖,可以参考以下两个步骤:

  (1)找到被测试对象使用的外部接口或者API;

  (2)把这个接口的底层实现替换成你能控制的东西;

  对于我们的LogAn项目,我们要做到替代实例不会访问文件系统,这样便破除了文件系统的依赖性。因此,我们可以引入一个间接层来避免对文件系统的直接依赖。访问文件系统的代码被隔离在一个FileExtensionManager类中,这个类之后将会被一个存根类替代,如下图所示:

  在上图中,我们引入了存根 ExtensionManagerStub 破除依赖,现在我们得代码不应该知道也不会关心它使用的扩展管理器的内部实现。

1.5 重构代码提高可测试性

  有两类打破依赖的重构方法,二者相互依赖,他们被称为A型和B型重构。

  (1)A型 把具体类抽象成接口或委托;

  下面我们实践抽取接口将底层实现变为可替换的,继续上述的IsValidLogFileName方法。

  Step1.我们将和文件系统打交道的代码分离到一个单独的类中,以便将来在代码中替换带对这个类的调用。

  ①使用抽取出的类

 

  ②定义抽取出的类

 

  Step2.然后我们从一个已知的类FileExtensionManager抽取出一个接口IExtensionManager。

 

  Step3.创建一个实现IExtensionManager接口的简单存根代码作为可替换的底层实现。

 

  于是,IsValidLogFileName方法就可以进行重构了:

 

  但是,这里被测试方法还是对具体类进行直接调用,我们必须想办法让测试方法调用伪对象而不是IExtensionManager的原本实现,于是我们想到了DI(依赖注入),这时就需要B型重构。

  (2)B型 重构代码,从而能够对其注入这种委托和接口的伪实现。

  刚刚我们想到了依赖注入,依赖注入的主要表现形式就是构造函数注入与属性注入,于是这里我们主要来看看构造函数层次与属性层次如何注入一个伪对象。

  ① 通过构造函数注入伪对象

  根据上图所示的流程,我们可以重构LogAnalyzer代码:

 

  其次,再添加新的测试代码:

 

Note:这里将伪存根类和测试代码放在一个文件里,因为目前这个伪对象只在这个测试类内部使用。它比起手工实现的伪对象和测试代码放在不同文件中,将它们放在一个文件里的话,定位、阅读以及维护代码都要容易的多。  

  ② 通过属性设置注入伪对象

  构造函数注入只是方法之一,属性也经常用来实现依赖注入。

  根据上图所示的流程,我们可以重构LogAnalyzer类:

 

  其次,新增一个测试方法,改为属性注入方式:

 

Note : 如果你想表明被测试类的某个依赖项是可选的,或者测试可以放心使用默认创建的这个依赖项实例,这时你就可以使用属性注入。

1.6 抽取和重写

  抽取和重写是一项强大的技术,可直接替换依赖项,实现起来快速干净,可以让我们编写更少的接口、更多的虚函数。

  还是继续上面的例子,首先改造被测试类(位于Manulife.LogAn),添加一个返回真实实例的虚工厂方法,正常在代码中使用工厂方法:

 

  其次,在改造测试项目(位于Manulife.LogAn.UnitTests),创建一个新类,声明这个新类继承自被测试类,创建一个我们要替换的接口(IExtensionManager)类型的公共字段(不需要属性get和set方法):

 

  最后,改造测试代码,这里我们创建的是新派生类而非被测试类的实例,配置这个新实例的公共字段,设置成我们在测试中创建的存根实例FakeExtensionManager:

 

二、交互测试-模拟对象

  工作单元可能有三种最终结果,目前为止,我们编写过的测试只针对前两种:返回值和改变系统状态。现在,我们来了解如何测试第三种最终结果-调用第三方对象。

2.1 模拟对象与存根的区别

  模拟对象和存根之间的区别很小,但二者之间的区别非常微妙,但又很重要。二者最根本的区别在于:

存根不会导致测试失败,而模拟对象可以。

  下图展示了存根和模拟对象之间的区别,可以看到测试会使用模拟对象验证测试是否失败。

2.2 第一个手工模拟对象

  创建和使用模拟对象的方法与使用存根类似,只是模拟对象比存根多做一件事:它保存通讯的历史记录,这些记录之后用于预期(Expection)验证。

  假设我们的被测试项目LogAnalyzer需要和一个外部的Web Service交互,每次LogAnalyzer遇到一个过短的文件名,这个Web Service就会收到一个错误消息。遗憾的是,要测试的这个Web Service还没有完全实现。就算实现了,使用这个Web Service也会导致测试时间过长。

  因此,我们需要重构设计,创建一个新的接口,之后用于这个接口创建模拟对象。这个接口只包括我们需要调用的Web Service方法。

  Step1.抽取接口,被测试代码可以使用这个接口而不是直接调用Web Service。然后创建实现接口的模拟对象,它看起来十分像存根,但是它还存储了一些状态信息,然后测试可以对这些信息进行断言,验证模拟对象是否正确调用。

 

  Step2.在被测试类中使用依赖注入(这里是构造函数注入)消费Web Service:

 

  Step3.使用模拟对象测试LogAnalyzer:

 

  可以看出,这里的测试代码中我们是对模拟对象进行断言,而非LogAnalyzer类,因为我们测试的是LogAnalyzer和Web Service之间的交互

2.3 同时使用模拟对象和存根

  假设我们得LogAnalyzer不仅需要调用Web Service,而且如果Web Service抛出一个错误,LogAnalyzer还需要把这个错误记录在另一个外部依赖项里,即把错误用电子邮件发送给Web Service管理员,如下代码所示:

 

  可以看出,这里LogAnalyzer有两个外部依赖项:Web Service和电子邮件服务。我们看到这段代码只包含调用外部对象的逻辑,没有返回值,也没有系统状态的改变,那么我们如何测试当Web Service抛出异常时LogAnalyzer正确地调用了电子邮件服务呢?

  我们可以在测试代码中使用存根替换Web Service来模拟异常,然后模拟邮件服务来检查调用。测试的内容是LogAnalyzer与其他对象的交互。

  Step1.抽取Email接口,封装Email类

 

  Step2.封装EmailInfo类,重写Equals方法

 

  Step3.创建FakeEmailService模拟对象,改造FakeWebService为存根

 

  Step4.改造LogAnalyzer类适配两个Service

 

  Step5.编写测试代码,创建预期对象,并使用预期对象断言所有的属性

 

总结:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定工作单元三种最终结果中的一个,不然的话天下大乱。

三、隔离(模拟)框架

3.1 为何使用隔离框架

  对于复杂的交互场景,可能手工编写模拟对象和存根就会变得很不方便,因此,我们可以借助隔离框架来帮我们在运行时自动生成存根和模拟对象。

一个隔离框架是一套可编程的API,使用这套API创建伪对象比手工编写容易得多,快得多,而且简洁得多。

  隔离框架的主要功能就在于帮我们生成动态伪对象,动态伪对象是运行时创建的任何存根或者模拟对象,它的创建不需要手工编写代码(硬编码)。

3.2 关于NSubstitute隔离框架

  Nsubstitute是一个开源的框架,源码是C#实现的。你可以在这里获得它的源码:https://github.com/nsubstitute/NSubstitute

  NSubstitute 更注重替代(Substitute)概念。它的设计目标是提供一个优秀的测试替代的.NET模拟框架。它是一个模拟测试框架,用最简洁的语法,使得我们能够把更多的注意力放在测试工作,减轻我们的测试配置工作,以满足我们的测试需求,帮助完成测试工作。它提供最经常需要使用的测试功能,且易于使用,语句更符合自然语言,可读性更高。对于单元测试的新手或只专注于测试的开发人员,它具有简单、友好的语法,使用更少的lambda表达式来编写完美的测试程序。

  NSubstitute 采用的是Arrange-Act-Assert测试模式,你只需要告诉它应该如何工作,然后断言你所期望接收到的请求,就大功告成了。因为你有更重要的代码要编写,而不是去考虑是需要一个Mock还是一个Stub。

  在.NET项目中,我们仍然可以通过NuGet来安装NSubsititute:

3.3 使用NSubstitute模拟对象

  NSub是一个受限框架,它最适合为接口创建伪对象。我们继续以前的例子,来看下面一段代码,它是一个手写的伪对象FakeLogger,它会检查日志调用是否正确执行。此处我们没有使用隔离框架。

 

  现在我们看看如何使用NSub伪造一个对象,换句话说,之前我们手动写的FakeLogger在这里就不用再手动写了:

 

  需要注意的是:

  (1)ILogger接口自身并没有这个Received方法;

  (2)NSub命名空间提供了一个扩展方法Received,这个方法可以断言在测试中调用了伪对象的某个方法;

  (3)通过在LogError()前调用Received(),其实是NSub在询问伪对象的这个方法是否调用过。

3.4 使用NSubstitute模拟值

  如果接口的方法返回不为空,如何从实现接口的动态伪对象返回一个值呢?我们可以借助NSub强制方法返回一个值:

 

  如果我们不想关心方法的参数,即无论参数是什么,方法应该总是返回一个价值,这样的话测试会更容易维护,因此我们可以借助NSub的参数匹配器:

 

  Arg.Any<Type>称为参数匹配器,在隔离框架中被广泛使用,控制参数处理。

  如果我们需要模拟一个异常,也可以借助NSub来解决:

 

  这里,使用了Assert.Throws验证被测试方法确实抛出了一个异常。When和Do两个方法顾名思义代表了什么时候发生了什么事,发生了事之后要触发其他什么事。需要注意的是,这里When方法必须使用Lambda表达式。

3.5 同时使用模拟对象和存根

  这里我们在一个场景中结合使用两种类型的伪对象:一个用作存根,另一个用作模拟对象。

  继续前面的一个例子,LogAnalyzer要使用一个MailServer类和一个WebService类,这次需求有变化:如果日志对象抛出异常,LogAnalyzer需要通知Web服务,如下图所示:

  我们需要确保的是:如果日志对象抛出异常,LogAnalyzer会把这个问题通知WebService。下面是被测试类的代码:

 

  现在我们借助NSubstitute进行测试:

 

  这里我们不需要手工实现伪对象,但是代码的可读性已经变差了,因为有一堆Lambda表达式,不过它也帮我们避免了在测试中使用方法名字符串。

四、小结

  本篇我们学习了单元测试的核心技术:存根、模拟对象以及隔离框架。使用存根可以帮助我们破除依赖,模拟对象与存根的区别主要在于存根不会导致测试失败,而模拟对象则可以。要辨别你是否使用了存根,最简单的方法是:存根永远不会导致测试失败,测试总是对被测试类进行断言。使用隔离框架,测试代码会更加易读、易维护,重点是可以帮助我们节省不少时间编写模拟对象和存根。

参考资料

  (1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》

  (2)匠心十年,《NSubsititue完全手册

  (3)张善友,《单元测试模拟框架:NSubstitute

NET单元测试的艺术的更多相关文章

  1. .NET单元测试的艺术-1.入门

    开篇:最近在看Roy Osherove的<单元测试的艺术>一书,颇有收获.因此,将其记录下来,并分为四个部分分享成文,与各位Share.本篇作为入门,介绍了单元测试的基础知识,例如:如何使 ...

  2. .NET单元测试的艺术-2.核心技术

    开篇:上一篇我们学习基本的单元测试基础知识和入门实例.但是,如果我们要测试的方法依赖于一个外部资源,如文件系统.数据库.Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需 ...

  3. .NET单元测试的艺术-3.测试代码

    开篇:上一篇我们学习单元测试和核心技术:存根.模拟对象和隔离框架,它们是我们进行高质量单元测试的技术基础.本篇会集中在管理和组织单元测试的技术,以及如何确保在真实项目中进行高质量的单元测试. 系列目录 ...

  4. [Test] 单元测试艺术(1) 基础知识

    单元测试不是软件开发的新概念,在1970年就一直存在,屡屡被证明是最理想的方法之一. 本系列将分成3节: 单元测试基础知识 打破依赖,使用模拟对象,桩对象,测试框架 创建优秀的单元测试 本节索引: 单 ...

  5. Unity 单元测试(NUnit,UnityTestTools)

    在软件开发中单元测试是非常重要的一个环节, =.=盘子脸去了几家公司都没有单元测试这个概念. 我们的系统虽然从代码看上是分离的, 在多数情况下都需要依赖于其他模块来运行.(单元测试部分内容教我解决这个 ...

  6. go单元测试进阶篇

    作者介绍:熊训德(英文名:Sundy),16年毕业于四川大学大学并加入腾讯.目前在腾讯云从事hadoop生态相关的云存储和计算等后台开发,喜欢并专注于研究大数据.虚拟化和人工智能等相关技术. 本文档说 ...

  7. 持续集成之单元测试篇——WWH(讲讲我们做单元测试的故事)

    持续集成之单元测试篇--WWH(讲讲我们做单元测试的故事) 前言 临近上线的几天内非重大bug不敢进行发版修复,担心引起其它问题(摁下葫芦浮起瓢) 尽管我们如此小心,仍不能避免修改一些bug而引起更多 ...

  8. .Net单元测试业务实践

    使用次数和允许取消次数单元测试实践 /** * prism.js Github theme based on GitHub's theme. * @author Sam Clarke */ code[ ...

  9. VisualStudio中的单元测试

    1. VisualStuio中的测试资源管理器.CodeLens和ReSharper 上一篇文章重温了<单元测试的艺术>里提到的单元测试的技术及原则.这篇文章实践使用VisualStudi ...

随机推荐

  1. js动态向页面中添加表格

    我们在实际开发中经常会想要实现如下情况: 点击某个按钮,然后动态的网页面里面添加一个表格或者一行,这个更加灵活方便.但是实现起来肯定不能像在页面里面直接写标签来的容易,以下是我项目中的代码,拿过来分享 ...

  2. maven常见问题汇总

    package阶段得到的是build目录下编译后的类包(jar),install是把这个包和一些maven的元信息(比如pom.xml)复制到本地仓库,assembly一般是把build结果和一些资源 ...

  3. Python 数据分析(一) 本实验将学习 pandas 基础,数据加载、存储与文件格式,数据规整化,绘图和可视化的知识

    第1节 pandas 回顾 第2节 读写文本格式的数据 第3节 使用 HTML 和 Web API 第4节 使用数据库 第5节 合并数据集 第6节 重塑和轴向旋转 第7节 数据转换 第8节 字符串操作 ...

  4. Learning Lua Programming (4) Cocos2d-x中Lua编程(一)

    刚开始接触cocos2d-x 下的Lua编程,主要参看了李华明大神的博客中的介绍,http://blog.csdn.net/xiaominghimi/article/category/1155088  ...

  5. 第14周 项目三-OOP版电子词典

    做一个简单的电子词典.在文件dictionary.txt中,保存的是英汉对比的一个词典,词汇量近8000个,英文.中文释义与词性间用'\t'隔开. (1)编程序,由用户输入英文词.显示词性和中文释义. ...

  6. 向架构师进军---&gt;系统架构设计基础知识

    假设你对项目管理.系统架构有兴趣,请加微信订阅号“softjg”,增加这个PM.架构师的大家庭 在解说系统架构设计之前,有必要补充一下架构相关的概念,因此本博文主要讲述架构.架构师和架构设计等相关的概 ...

  7. [ACM] HDU 5083 Instruction (模拟)

    Instruction Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others) Tota ...

  8. wcf 出现 IsContentTypeSupported 错误

    查看添加的服务地址是不是https开头的,而 *.config 文件里面自动添加的链接变成了http,当前的bindbing类型为basicHttpBinding, 解决方法:在config文件里面手 ...

  9. HTTP协议--简析

    HTTP--超文本传输协议(HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议,是所有的www文件都必须遵守的标准. 要想成为优秀的web开发人员,必须熟悉H ...

  10. Silverlight技术调查(1)——Html向Silverlight传参

    原文 Silverlight技术调查(1)——Html向Silverlight传参 近几日项目研究一个很牛的富文档编辑器DXperience RichEdit组件,调查环境为Silverlight4. ...