.NET单元测试的艺术-2.核心技术
开篇:上一篇我们学习基本的单元测试基础知识和入门实例。但是,如果我们要测试的方法依赖于一个外部资源,如文件系统、数据库、Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需要创建测试存根、伪对象及模拟对象。这一篇中我们会开始接触这些核心技术,借助存根破除依赖,使用模拟对象进行交互测试,使用隔离框架支持适应未来和可用性的功能。
系列目录:
1.入门
2.核心技术
3.测试代码
一、破除依赖-存根
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之间的交互。
2.3 同时使用模拟对象和存根
假设我们得LogAnalyzer不仅需要调用Web Service,而且如果Web Service抛出一个错误,LogAnalyzer还需要把这个错误记录在另一个外部依赖项里,即把错误用电子邮件发送给Web Service管理员,如下代码所示:
if (fileName.Length < 8) { try { // 在产品代码中写错误日志 service.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { email.SendEmail("a", "subject", ex.Message); } }
可以看出,这里LogAnalyzer有两个外部依赖项:Web Service和电子邮件服务。我们看到这段代码只包含调用外部对象的逻辑,没有返回值,也没有系统状态的改变,那么我们如何测试当Web Service抛出异常时LogAnalyzer正确地调用了电子邮件服务呢?
我们可以在测试代码中使用存根替换Web Service来模拟异常,然后模拟邮件服务来检查调用。测试的内容是LogAnalyzer与其他对象的交互。
Step1.抽取Email接口,封装Email类
public interface IEmailService { void SendEmail(EmailInfo emailInfo); } public class EmailInfo { public string Body; public string To; public string Subject; public EmailInfo(string to, string subject, string body) { this.To = to; this.Subject = subject; this.Body = body; } public override bool Equals(object obj) { EmailInfo compared = obj as EmailInfo; return To == compared.To && Subject == compared.Subject && Body == compared.Body; } }
Step2.封装EmailInfo类,重写Equals方法
public class EmailInfo { public string Body; public string To; public string Subject; public EmailInfo(string to, string subject, string body) { this.To = to; this.Subject = subject; this.Body = body; } public override bool Equals(object obj) { EmailInfo compared = obj as EmailInfo; return To == compared.To && Subject == compared.Subject && Body == compared.Body; } }
Step3.创建FakeEmailService模拟对象,改造FakeWebService为存根
public class FakeEmailService : IEmailService { public EmailInfo email = null; public void SendEmail(EmailInfo emailInfo) { this.email = emailInfo; } } public class FakeWebService : IWebService { public Exception ToThrow; public void LogError(string message) { if (ToThrow != null) { throw ToThrow; } } }
Step4.改造LogAnalyzer类适配两个Service
public class LogAnalyzer { private IWebService webService; private IEmailService emailService; public LogAnalyzer(IWebService webService, IEmailService emailService) { this.webService = webService; this.emailService = emailService; } public void Analyze(string fileName) { if (fileName.Length < 8) { try { webService.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { emailService.SendEmail(new EmailInfo("someone@qq.com", "can't log", ex.Message)); } } } }
Step5.编写测试代码,创建预期对象,并使用预期对象断言所有的属性
[Test] public void Analyze_WebServiceThrows_SendsEmail() { FakeWebService stubService = new FakeWebService(); stubService.ToThrow = new Exception("fake exception"); FakeEmailService mockEmail = new FakeEmailService(); LogAnalyzer log = new LogAnalyzer(stubService, mockEmail); string tooShortFileName = "abc.ext"; log.Analyze(tooShortFileName); // 创建预期对象 EmailInfo expectedEmail = new EmailInfo("someone@qq.com", "can't log", "fake exception"); // 用预期对象同时断言所有属性 Assert.AreEqual(expectedEmail, mockEmail.email); }
总结:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定工作单元三种最终结果中的一个,不然的话天下大乱。
三、隔离(模拟)框架
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,它会检查日志调用是否正确执行。此处我们没有使用隔离框架。
public interface ILogger { void LogError(string message); } public class FakeLogger : ILogger { public string LastError; public void LogError(string message) { LastError = message; } } [Test] public void Analyze_TooShortFileName_CallLogger() { // 创建伪对象 FakeLogger logger = new FakeLogger(); MyLogAnalyzer analyzer = new Chapter5.MyLogAnalyzer(logger); analyzer.MinNameLength = 6; analyzer.Analyze("a.txt"); StringAssert.Contains("too short", logger.LastError); }
现在我们看看如何使用NSub伪造一个对象,换句话说,之前我们手动写的FakeLogger在这里就不用再手动写了:
[Test] public void Analyze_TooShortFileName_CallLogger() { // 创建模拟对象,用于测试结尾的断言 ILogger logger = Substitute.For<ILogger>(); MyLogAnalyzer analyzer = new MyLogAnalyzer(logger); analyzer.MinNameLength = 6; analyzer.Analyze("a.txt"); // 使用NSub API设置预期字符串 logger.Received().LogError("Filename too short : a.txt"); }
需要注意的是:
(1)ILogger接口自身并没有这个Received方法;
(2)NSub命名空间提供了一个扩展方法Received,这个方法可以断言在测试中调用了伪对象的某个方法;
(3)通过在LogError()前调用Received(),其实是NSub在询问伪对象的这个方法是否调用过。
3.4 使用NSubstitute模拟值
如果接口的方法返回不为空,如何从实现接口的动态伪对象返回一个值呢?我们可以借助NSub强制方法返回一个值:
[Test] public void Returns_ByDefault_WorksForHardCodeArgument() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); // 强制方法返回假值 fakeRules.IsValidLogFileName("strict.txt").Returns(true); Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt")); }
如果我们不想关心方法的参数,即无论参数是什么,方法应该总是返回一个价值,这样的话测试会更容易维护,因此我们可以借助NSub的参数匹配器:
[Test] public void Returns_ByDefault_WorksForAnyArgument() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); // 强制方法返回假值 fakeRules.IsValidLogFileName(Arg.Any<string>()).Returns(true); Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt")); }
Arg.Any<Type>称为参数匹配器,在隔离框架中被广泛使用,控制参数处理。
如果我们需要模拟一个异常,也可以借助NSub来解决:
[Test] public void Returns_ArgAny_Throws() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); fakeRules.When(x => x.IsValidLogFileName(Arg.Any<string>())). Do(context => { throw new Exception("fake exception"); }); Assert.Throws<Exception>(() => fakeRules.IsValidLogFileName("anything")); }
这里,使用了Assert.Throws验证被测试方法确实抛出了一个异常。When和Do两个方法顾名思义代表了什么时候发生了什么事,发生了事之后要触发其他什么事。需要注意的是,这里When方法必须使用Lambda表达式。
3.5 同时使用模拟对象和存根
这里我们在一个场景中结合使用两种类型的伪对象:一个用作存根,另一个用作模拟对象。
继续前面的一个例子,LogAnalyzer要使用一个MailServer类和一个WebService类,这次需求有变化:如果日志对象抛出异常,LogAnalyzer需要通知Web服务,如下图所示:
我们需要确保的是:如果日志对象抛出异常,LogAnalyzer会把这个问题通知WebService。下面是被测试类的代码:
public interface IWebService { void Write(string message); } public class LogAnalyzerNew { private ILogger _logger; private IWebService _webService; public LogAnalyzerNew(ILogger logger, IWebService webService) { _logger = logger; _webService = webService; } public int MinNameLength { get; set; } public void Analyze(string fileName) { if (fileName.Length < MinNameLength) { try { _logger.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { _webService.Write("Error From Logger : " + ex.Message); } } } }
现在我们借助NSubstitute进行测试:
[Test] public void Analyze_LoggerThrows_CallsWebService() { var mockWebService = Substitute.For<IWebService>(); var stubLogger = Substitute.For<ILogger>(); // 无论输入什么都抛出异常 stubLogger.When(logger => logger.LogError(Arg.Any<string>())) .Do(info => { throw new Exception("fake exception"); }); var analyzer = new LogAnalyzerNew(stubLogger, mockWebService); analyzer.MinNameLength = 10; analyzer.Analyze("short.txt"); //验证在测试中调用了Web Service的模拟对象,调用参数字符串包含 "fake exception" mockWebService.Received().Write(Arg.Is<string>(s => s.Contains("fake exception"))); }
这里我们不需要手工实现伪对象,但是代码的可读性已经变差了,因为有一堆Lambda表达式,不过它也帮我们避免了在测试中使用方法名字符串。
四、小结
本篇我们学习了单元测试的核心技术:存根、模拟对象以及隔离框架。使用存根可以帮助我们破除依赖,模拟对象与存根的区别主要在于存根不会导致测试失败,而模拟对象则可以。要辨别你是否使用了存根,最简单的方法是:存根永远不会导致测试失败,测试总是对被测试类进行断言。使用隔离框架,测试代码会更加易读、易维护,重点是可以帮助我们节省不少时间编写模拟对象和存根。
参考资料
(1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》
(2)匠心十年,《NSubsititue完全手册》
(3)张善友,《单元测试模拟框架:NSubstitute》