在我们单元测试的实践中,常常会发现一个方法依赖一个无法控制的对象,我们称其为外部依赖项。
一个外部依赖项——是系统中的一个对象,被测试代码与这个对象发生交互,但你不能控制这个对象。(常见的外部依赖项包括文件系统、线程、内存以及时间等。)
而单元测试背后的思想是,仅测试这个方法中的内容,当测试开始渗透到其他类、服务或系统时,此时测试便跨越了边界。而一旦测试跨了边界就变成了集成测试。进而也带来了所有与集成测试相关的问题——运行速度较慢,需要配置,一次测试多个内容……
1 桩对象(存根)
什么是桩对象(存根)
一个存根(桩对象)(stub)是对系统中存在的一个依赖项(或者协作者)的可控制的替代物。通过使用存根,你在测试代码时无需直接处理这个依赖项。
如何使用桩对象(存根)破除依赖
示例1
假设我们有下面这样一个方法,从文件系统中读取一个文件,获取文件的扩展名,如果扩展名是jpg就返回true,否则返回false。
IFileExtensionManager fileManager;
public bool IsValidFileName(){
//获取文件扩展名
string extName=fileManager.GetExtName();
if(extName=="jpg"){
return true;
}
return false;
}
public class FileExtensionManager:IFileExtensionManager{
public string GetExtName(){
//调用真实的文件系统获取文件
return file.GetExtension();
}
}
很明显在这个方法中,我们要测的逻辑是扩展名是jpg就返回true,否则返回false。这个方法我们依赖一个外部方法FileExtensionManage.GetExtName()(获取文件的扩展名)。
使用存根破除依赖一般有下面几个步骤
- 找到被测试对象使用的外部接口
- 把这个接口的底层实现替换成你能控制的东西。
IFileExtensionManager fileManager;
public bool IsValidFileName(){
//获取文件扩展名
string extName=fileManager.GetExtName();
if(extName=="jpg"){
return true;
}
return false;
}
public class StubFileExtensionManager:IFileExtensionManager{
public string GetExtName(){
// 模拟文件系统的返回结果
return "jpg";
}
}
我们所创造的替代实例StubFileExtensionManager根本不会访问文件系统,这样就破除了对文件系统的依赖性。因为要测试的不是访问文件系统的类,而是调用这个类的代码,这个时候我们的的依赖关系就变成了下面这样
示例2
在上面的示例中,我们的被测试类与文件系统帮助类并非是强依赖的,而是依赖倒置的(通过接口IFileExtensionManager解耦),而在有些系统中,对于文件系统的访问类可能是下面这样的
public bool IsValidFileName(){
//获取文件扩展名
string extName=new FileExtensionManager().GetExtName();
if(extName=="jpg"){
return true;
}
return false;
}
这种情况下由于代码的不可测试性,我们就需要先对代码进行重构。使其更具有可测试性(注意:可测试性同样是我们编码所需要注意的原则之一)
- 找到被测试的工作单元依赖的外部对象。
- 如果这个外部对象与被测试工作单元直接相连(本例中,你直接读取文件系统),就在代码中添加一个间接层。
- 把这个交互接口的底层实現替换成你可以控制的代码。
此时变成了示例1的情况,就可以进行测试。
而在实践过程中,我们还会遇到许多难以测试的代码,这时就需要通过重构来提高其可测试性。关于如何是代码变得更加容易测试,后续文章继续总结。
2 模拟对象
什么是模拟对象
模拟对象可以验证被测试对象是否接预期的方式调用了这个伪对象,因此导致单元测试通过或是失败。
模拟对象主要用来做交互性测试,例如:调用一个第三方日志系统,你所调用的方法并不会返回任何东西,我们如何判断是否调用正确,甚至是否发生了调用。
如何利用模拟对象进行交互测试
如下示例,在我们的业务方法中如果文件名的长度大于8就要记录一个warn日志。这个方法不返回任何值,其所调用的日志系统的方法也不返回任何值。这个时候我们要验证是否如期调用了日志系统的warn方法。
public class FlieService{
ILogger logger;
public FlieService(ILogger logger){
this.logger=logger;
}
// 被测方法
public void LogValidResult(string fileName){
if(fileName.length>8){
logger.warn("invalid ...",obj);
}
}
}
//测试方法
[Test]
public void LogValidResult_Valid_Logger(){
string fileName="hello world"
var logger=new MockLogger();
new FileService(logger).LogValidResult();
string expect="invalid ...";
string actual=logger.Title;
Assert.AreEqual(expect,actual);
}
// 模拟对象
public class MockLogger:ILogger{
public string Title{get;set;}
public void info(string title,object obj){
}
}
3 伪对象、模拟对象与桩对象
伪对象
伪对象是通用的术语,可以描述一个存根或者模拟对象(手工或非手工編写),因为存根和模拟对象看上去都很像真实对象。一个伪对象究竟是存根还是模拟对象取决于它在当前测试中的使用方式:如果这个伪对象用来检验一个交互(对其进行断言),它就是模拟对象,否则就是存根
模拟对象与桩对象的区别
乍一看模拟对象与桩对象很相似,或者根本不存在区别。但区分二者又很重要,因为会使用这两个词来描述框架的各种不同行为。
二者最根本的区别是存根不会导致测试失败,而模拟对象可以
要辨别你是否使用了存根,最简单的方法是:存根永远不会导致测试失败。测试总是对被测试类进行断言
另一方面,测试会使用模拟对象验证測试是否失败。下图展示了测试和模拟对象之前的交互。
4 小结
本文简单总结了,当单元测试遇到外部依赖对象的时候我们通过桩对象来破除依赖,而在涉及验证是否正确调用一个外部对象的时候,我们可以使用模拟对象来进行交互测试。
可以看到这里我们用来创造伪对象都是通过自己手写代码的方式,而真实项目中有时候可能需要多个伪对象,那么又有什么好的方式呢。实际上现在无论是.net和java为了更好的单测已经产生了许多好用的单测框架与模拟框架。弄明白单测的一些基本思想,再熟练的运用好这些框架,将会让我们的单元测试进行的更加如鱼得水。