来源:Edison Chou
链接:http://www.cnblogs.com/edisonchou/p/5447812.html
开篇:上一篇我们学习基本的单元测试基础知识和入门实例。但是,如果我们要测试的方法依赖于一个外部资源,如文件系统、数据库、Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需要创建测试存根、伪对象及模拟对象。这一篇中我们会开始接触这些核心技术,借助存根破除依赖,使用模拟对象进行交互测试,使用隔离框架支持适应未来和可用性的功能。
一、破除依赖-存根
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.我们将和文件系统打交道的代码分离到一个单独的类中,以便将来在代码中替换带对这个类的调用。
①使用抽取出的类
public bool IsValidLogFileName(string fileName) { FileExtensionManager manager = new FileExtensionManager(); return manager.IsValid(fileName); }
②定义抽取出的类
public class FileExtensionManager : IExtensionManager { public bool IsValid(string fileName) { bool result = false; // 读取文件 return result; } }
Step2.然后我们从一个已知的类FileExtensionManager抽取出一个接口IExtensionManager。
public interface IExtensionManager { bool IsValid(string fileName); }
Step3.创建一个实现IExtensionManager接口的简单存根代码作为可替换的底层实现。
public class AlwaysValidFakeExtensionManager : IExtensionManager { public bool IsValid(string fileName) { return true; } }
于是,IsValidLogFileName方法就可以进行重构了:
public bool IsValidLogFileName(string fileName)
{ IExtensionManager manager = new FileExtensionManager(); return manager.IsValid(fileName); }
但是,这里被测试方法还是对具体类进行直接调用,我们必须想办法让测试方法调用伪对象而不是IExtensionManager的原本实现,于是我们想到了DI(依赖注入),这时就需要B型重构。
(2)B型 重构代码,从而能够对其注入这种委托和接口的伪实现。
刚刚我们想到了依赖注入,依赖注入的主要表现形式就是构造函数注入与属性注入,于是这里我们主要来看看构造函数层次与属性层次如何注入一个伪对象。 ① 通过构造函数注入伪对象
根据上图所示的流程,我们可以重构LogAnalyzer代码:
public class LogAnalyzer { private IExtensionManager manager; public LogAnalyzer(IExtensionManager manager) { this.manager = manager; } public bool IsValidLogFileName(string fileName) { return manager.IsValid(fileName); } }
其次,再添加新的测试代码:
[TestFixture]
public class LogAnalyzerTests { [Test] public void IsValidFileName_NameSupportExtension_ReturnsTrue() { // 准备一个返回true的存根 FakeExtensionManager myFakeManager = new FakeExtensionManager(); myFakeManager.WillBeValid = true; // 通过构造器注入传入存根 LogAnalyzer analyzer = new LogAnalyzer(myFakeManager); bool result = analyzer.IsValidLogFileName('short.ext'); Assert.AreEqual(true, result); } // 定义一个最简单的存根 internal class FakeExtensionManager : IExtensionManager { public bool WillBeValid = false; public bool IsValid(string fileName) { return WillBeValid; } } }
Note:这里将伪存根类和测试代码放在一个文件里,因为目前这个伪对象只在这个测试类内部使用。它比起手工实现的伪对象和测试代码放在不同文件中,将它们放在一个文件里的话,定位、阅读以及维护代码都要容易的多。
② 通过属性设置注入伪对象
构造函数注入只是方法之一,属性也经常用来实现依赖注入。
根据上图所示的流程,我们可以重构LogAnalyzer类:
public class LogAnalyzer { private IExtensionManager manager; // 允许通过属性设置依赖项 public IExtensionManager ExtensionManager { get { return manager; } set { manager = value; } } public LogAnalyzer() { this.manager = new FileExtensionManager(); } public bool IsValidLogFileName(string fileName) { return manager.IsValid(fileName); } }
其次,新增一个测试方法,改为属性注入方式:
[Test] public void IsValidFileName_SupportExtension_ReturnsTrue() { // 设置要使用的存根,确保其返回true FakeExtensionManager myFakeManager = new FakeExtensionManager(); myFakeManager.WillBeValid = true; // 创建analyzer,注入存根 LogAnalyzer log = new LogAnalyzer(); log.ExtensionManager = myFakeManager; bool result = log.IsValidLogFileName('short.ext'); Assert.AreEqual(true, result); }
Note : 如果你想表明被测试类的某个依赖项是可选的,或者测试可以放心使用默认创建的这个依赖项实例,这时你就可以使用属性注入。
1.6 抽取和重写
抽取和重写是一项强大的技术,可直接替换依赖项,实现起来快速干净,可以让我们编写更少的接口、更多的虚函数。
还是继续上面的例子,首先改造被测试类(位于Manulife.LogAn),添加一个返回真实实例的虚工厂方法,正常在代码中使用工厂方法:
public class LogAnalyzerUsingFactoryMethod { public bool IsValidLogFileName(string fileName) { // use virtual method return GetManager().IsValid(fileName); } protected virtual IExtensionManager GetManager() { // hard code return new FileExtensionManager(); } }
其次,在改造测试项目(位于Manulife.LogAn.UnitTests),创建一个新类,声明这个新类继承自被测试类,创建一个我们要替换的接口(IExtensionManager)类型的公共字段(不需要属性get和set方法):
public class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod { public IExtensionManager manager; public TestableLogAnalyzer(IExtensionManager manager) { this.manager = manager; } // 返回你指定的值 protected override IExtensionManager GetManager() { return this.manager; } }
最后,改造测试代码,这里我们创建的是新派生类而非被测试类的实例,配置这个新实例的公共字段,设置成我们在测试中创建的存根实例FakeExtensionManager:
[Test] public void OverrideTest() { FakeExtensionManager stub = new FakeExtensionManager(); stub.WillBeValid = true; // 创建被测试类的派生类的实例 TestableLogAnalyzer logan = new TestableLogAnalyzer(stub); bool result = logan.IsValidLogFileName('stubfile.ext'); Assert.AreEqual(true, result); }
二、交互测试-模拟对象
工作单元可能有三种最终结果,目前为止,我们编写过的测试只针对前两种:返回值和改变系统状态。现在,我们来了解如何测试第三种最终结果-调用第三方对象。
2.1 模拟对象与存根的区别
模拟对象和存根之间的区别很小,但二者之间的区别非常微妙,但又很重要。二者最根本的区别在于:
存根不会导致测试失败,而模拟对象可以。
下图展示了存根和模拟对象之间的区别,可以看到测试会使用模拟对象验证测试是否失败。
2.2 第一个手工模拟对象 创建和使用模拟对象的方法与使用存根类似,只是模拟对象比存根多做一件事:它保存通讯的历史记录,这些记录之后用于预期(Expection)验证。 假设我们的被测试项目LogAnalyzer需要和一个外部的Web Service交互,每次LogAnalyzer遇到一个过短的文件名,这个Web Service就会收到一个错误消息。遗憾的是,要测试的这个Web Service还没有完全实现。就算实现了,使用这个Web Service也会导致测试时间过长。 因此,我们需要重构设计,创建一个新的接口,之后用于这个接口创建模拟对象。这个接口只包括我们需要调用的Web Service方法。
Step1.抽取接口,被测试代码可以使用这个接口而不是直接调用Web Service。然后创建实现接口的模拟对象,它看起来十分像存根,但是它还存储了一些状态信息,然后测试可以对这些信息进行断言,验证模拟对象是否正确调用。
public interface IWebService { void LogError(string message); } public class FakeWebService : IWebService { public string LastError; public void LogError(string message) { this.LastError = message; } }
Step2.在被测试类中使用依赖注入(这里是构造函数注入)消费Web Service:
public class LogAnalyzer { private IWebService service; public LogAnalyzer(IWebService service) { this.service = service; } public void Analyze(string fileName) { if (fileName.Length 8) { // 在产品代码中写错误日志 service.LogError(string.Format('Filename too short : {0}',fileName)); } } }
Step3.使用模拟对象测试LogAnalyzer:
[Test] public void Analyze_TooShortFileName_CallsWebService() { FakeWebService mockService = new FakeWebService(); LogAnalyzer log = new LogAnalyzer(mockService); string tooShortFileName = 'abc.ext'; log.Analyze(tooShortFileName); // 使用模拟对象进行断言 StringAssert.Contains('Filename too short : abc.ext', mockService.LastError); }
可以看出,这里的测试代码中我们是对模拟对象进行断言,而非LogAnalyzer类,因为我们测试的是LogAnalyzer和Web Service之间的交互。
|