Photo :Unit Test in Visual Studio
文 | Edison Zhou
上一篇我们学习基本的单元测试基础知识和入门实例。但是,如果我们要测试的方法依赖于一个外部资源,如文件系统、数据库、Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需要创建测试存根、伪对象及模拟对象。
这一篇中我们会开始接触这些核心技术,借助存根破除依赖,下一篇我们会使用模拟对象进行交互测试,再下一篇我们则进一步使用隔离框架支持适应未来和可用性的功能。
破除依赖之存根
为何使用存根?
当我们要测试的对象依赖另一个你无法控制(或者还未实现)的对象,这个对象可能是Web服务、系统时间、线程调度或者很多其他东西。
那么重要的问题来了:你的测试代码不能控制这个依赖的对象向你的代码返回什么值,也不能控制它的行为(例如你想摸你一个异常)。
因此,这种情况下你可以使用存根。
存根的简要介绍
(1)外部依赖项
一个外部依赖项是系统中的一个对象,被测试代码与这个对象发生交互,但你不能控制这个对象。(常见的外部依赖项包括:文件系统、线程、内存以及时间等)
(2)存根
一个存根(Stub)是对系统中存在的一个依赖项(或者协作者)的可控制的替代物。通过使用存根,你在测试代码时无需直接处理这个依赖项。
发现项目中的外部依赖
继续上一篇中的LogAn案例,假设我们的IsValidLogFilename方法会首先读取配置文件,如果配置文件说支持这个扩展名,就返回true:
public bool IsValidLogFileName(string fileName){// 读取配置文件// 如果配置文件说支持这个扩展名,则返回true}
那么问题来了:一旦测试依赖于文件系统,我们进行的就是集成测试,会带来所有与集成测试相关的问题—运行速度较慢,需要配置,一次测试多个内容等。
换句话说,尽管代码本身的逻辑是完全正确的,但是这种依赖可能导致测试失败。
避免项目中的直接依赖
想要破除直接依赖,可以参考以下两个步骤:
(1)找到被测试对象使用的外部接口或者API;
(2)把这个接口的底层实现替换成你能控制的东西;
对于我们的LogAn项目,我们要做到替代实例不会访问文件系统,这样便破除了文件系统的依赖性。因此,我们可以引入一个间接层来避免对文件系统的直接依赖。访问文件系统的代码被隔离在一个FileExtensionManager类中,这个类之后将会被一个存根类替代,如下图所示:
在上图中,我们引入了存根 ExtensionManagerStub 破除依赖,现在我们得代码不应该知道也不会关心它使用的扩展管理器的内部实现。
重构代码提高可测试性
一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行检验。
有两类打破依赖的重构方法,二者相互依赖,他们被称为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()
{// 设置要使用的存根,确保其返回trueFakeExtensionManager 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 : 如果你想表明被测试类的某个依赖项是可选的,或者测试可以放心使用默认创建的这个依赖项实例,这时你就可以使用属性注入。
抽取和重写
抽取和重写是一项强大的技术,可直接替换依赖项,实现起来快速干净,可以让我们编写更少的接口、更多的虚函数。
还是继续上面的例子,首先改造被测试类(位于Edison.LogAn),添加一个返回真实实例的虚工厂方法,正常在代码中使用工厂方法:
public class LogAnalyzerUsingFactoryMethod{public bool IsValidLogFileName(string fileName){// use virtual methodreturn GetManager().IsValid(fileName);}protected virtual IExtensionManager GetManager(){// hard codereturn new FileExtensionManager();}}
其次,在改造测试项目(位于Edison.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);}
小结
本篇我们开始了单元测试核心技术之一存根的学习,通过使用存根可以破除依赖。下一篇我们会使用模拟对象进行交互测试,再下一篇我们则进一步使用隔离框架支持适应未来和可用性的功能。
参考资料
(1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》
(2)匠心十年,《NSubsititue完全手册》
(3)张善友,《单元测试模拟框架:NSubstitute》
2020后记:虽然这是一篇发表于2015年的文章,但我至今觉得仍有价值。因为我发现在.NET圈,还是有很多童鞋不了解单元测试和不喜欢写单元测试,不懂其价值就不会形成增强回路。所谓增强回路,就是我单元测试写的越多,以后修改代码增加功能就不容易出现Bug(这里主要指SIT阶段、UAT阶段乃至线上),越不容易出现Bug我提交的代码质量就越高,就会增强我写单元测试的愿望,形成一个回路。在我现在的实践中,是把单元测试加入了持续集成构建任务中的,每次组员提交代码都会触发构建任务,去编译项目,去跑单元测试,只要单元测试没有跑过就会邮件或者通知发出来告诉我,我会知道是谁提交的代码居然没有跑单元测试就提交了,我就会找他改Bug了,呵呵。
The End
「 码字不易,也希望各位看官看完觉得还行就在本文右下方顺手点个“在看”,那就是对我最大的鼓励!如果觉得很好,也可以转发给你的朋友,让更多人看到,独乐乐不如众乐乐,是吧?」
往期精彩回顾
.NET Core on K8S学习与实践系列文章索引目录
.NET Core 微服务学习与实践系列文章索引目录
【资料】2019 .NET China Conf 大会资料下载
【视频】2019 .NET China Conf 大会视频发布
2019 .NET China Conf 路一直都在,社区会更好
基于Jenkins的开发测试全流程持续集成实践
基于Jenkins Pipeline的.NET Core持续集成实践
【导读】我读经典,心旷神怡 - 经典书籍读后感汇总
【导读】我的诗和远方 - 也读唐诗与旅游游记汇总
点个【在看】如何?