NET单元测试的艺术

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

作者:周旭龙

出处:http://edisonchou.cnblogs.com

时间: 2024-10-12 19:39:32

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

单元测试的艺术-入门篇

前记:前段时间团队在推行单元测试,对于分配的测试任务也很快的完成,但觉得自己对单元测试的理解也不够透彻,所以就买了<单元测试的艺术>这本书来寻找一些我想要的答案.这本书并不是手把手教你写单元测试代码的,而是教你一些思想,循序渐进,最终达到能够写出可靠的.可维护的.可读的测试.本篇文章是入门篇,主要是讲解单元测试的概念.与集成测试的区别以及如何使用框架进行最基础的单元测试等. 一.单元测试的基础 1.1.什么是单元测试 单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单

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

开篇:上一篇我们学习单元测试和核心技术:存根.模拟对象和隔离框架,它们是我们进行高质量单元测试的技术基础.本篇会集中在管理和组织单元测试的技术,以及如何确保在真实项目中进行高质量的单元测试. 系列目录: 1.入门 2.核心技术 3.测试代码 一.测试层次和组织 1.1 测试项目的两种目录结构 (1)集成测试和单元测试在同一个项目里,但放在不同的目录和命名空间里.基础类放在单独的文件夹里. (2)集成测试和单元测试位于不同的项目中,有不同的命名空间. 实践中推荐使用第二种目录结构,因为如果我们不把

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

开篇:最近在看Roy Osherove的<单元测试的艺术>一书,颇有收获.因此,将其记录下来,并分为四个部分分享成文,与各位Share.本篇作为入门,介绍了单元测试的基础知识,例如:如何使用一个测试框架,基本的自动化测试属性等等,还有对应的三种测试类型.相信你可以对编写单元测试从一无所知到及格水平,这也是原书作者的目标. 系列目录: 1.入门 2.核心技术 3.测试代码 4.设计和流程 一.单元测试基础 1.1 什么是单元测试 一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后

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

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

单元测试的艺术 ---- 系列文章

.NET单元测试的艺术-1.入门 .NET单元测试的艺术-2.核心技术 .NET单元测试的艺术-3.测试代码 出处:http://www.cnblogs.com/edisonchou/category/821397.html

《单元测试的艺术(第2版)》

<单元测试的艺术(第2版)> 基本信息 作者: (以)Roy Osherove 译者: 金迎 丛书名: 图灵程序设计丛书 出版社:人民邮电出版社 ISBN:9787115360359 上架时间:2014-7-25 出版日期:2014 年8月 开本:16开 页码:1 版次:1-1 所属分类:计算机 > 软件工程及软件方法学 > 设计模式 计算机 > 软件与程序设计 > 综合 更多关于>>> <单元测试的艺术(第2版)>   编辑推荐 基础概念

.NET 单元测试的艺术&amp;单元测试之道C#版

目录 1.单元测试概念 2.单元测试的原则 3.单元测试简单示例 4.单元测试框架特性标签 5.单元测试中的断言Assert 6.单元测试中验证预期的异常 7.单元测试中针对状态的间接测试 8.单元测试在MVC模式中的实现 8.单元测试相关参考 9.示例源代码下载 志铭-2020年1月23日 11:49:41 1.单元测试概念 什么是单元测试? 单元测试(unit testing)是一段自动化的代码,用来调用被测试的方法或类,而后验证基于该方法或类的逻辑行为的一些假设. 简而言之说:单元测试是一

《单元测试的艺术》读书笔记----使用存根破除依赖

存根的定义: 被测试类中存在的一个依赖项(或协作者)的可控制的代替物. 重构代码并使用存根: 可使用的办法有: 1.创建有参数的构造函数,初始化依赖项为存根对象: 2.使用set方法给测试对象设置伪对象: 原文地址:https://www.cnblogs.com/markcd/p/9005165.html

go单元测试进阶篇

本文档说明go语言自带的测试框架未提供或者未方便地提供的测试方案,主要是用于解决写单元测试中比较头痛的依赖问题.也就是伪造模式,经典的伪造模式有桩对象(stub),模拟对象(mock)和伪对象(fake).比较幸运的是,社区有丰富的第三方测试框架支持支持.下面就对笔者亲身试用并实践到项目中的几个框架做介绍: 1.gomock https://godoc.org/github.com/golang/mock/gomock gomock模拟对象的方式是让用户声明一个接口,然后使用gomock提供的m