Photo :Unit Test in Visual Studio
文 | Edison Zhou
上一篇我们学习了如何使用模拟对象进行交互测试。这一篇我们则会进一步使用隔离框架支持适应未来和可用性的功能。
为何使用模拟框架?
对于复杂的交互场景,可能手工编写模拟对象和存根就会变得很不方便,因此,我们可以借助隔离框架来帮我们在运行时自动生成存根和模拟对象。
一个隔离框架是一套可编程的API,使用这套API创建伪对象比手工编写容易得多,快得多,而且简洁得多。
隔离框架的主要功能就在于帮我们生成动态伪对象,动态伪对象是运行时创建的任何存根或者模拟对象,它的创建不需要手工编写代码(硬编码)。
关于NSub框架
Nsubstitute是一个开源的框架,源码是C#实现的。你可以在这里获得它的源码:https://github.com/nsubstitute/NSubstitute
NSubstitute 更注重替代(Substitute)概念。它的设计目标是提供一个优秀的测试替代的.NET模拟框架。它是一个模拟测试框架,用最简洁的语法,使得我们能够把更多的注意力放在测试工作,减轻我们的测试配置工作,以满足我们的测试需求,帮助完成测试工作。它提供最经常需要使用的测试功能,且易于使用,语句更符合自然语言,可读性更高。对于单元测试的新手或只专注于测试的开发人员,它具有简单、友好的语法,使用更少的lambda表达式来编写完美的测试程序。
NSubstitute 采用的是Arrange-Act-Assert测试模式,你只需要告诉它应该如何工作,然后断言你所期望接收到的请求,就大功告成了。因为你有更重要的代码要编写,而不是去考虑是需要一个Mock还是一个Stub。
在.NET项目中,我们仍然可以通过NuGet来安装NSubsititute:
使用NSub框架
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在询问伪对象的这个方法是否调用过。
使用NSub模拟返回值
如果接口的方法返回不为空,如何从实现接口的动态伪对象返回一个值呢?我们可以借助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表达式。
同时使用模拟对象与存根
这里我们在一个场景中结合使用两种类型的伪对象:一个用作存根,另一个用作模拟对象。
继续前面的一个例子,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表达式,不过它也帮我们避免了在测试中使用方法名字符串。
小结
这一系列文章我们学习了单元测试的核心技术:存根、模拟对象以及隔离(Mock)框架。使用存根可以帮助我们破除依赖,模拟对象与存根的区别主要在于存根不会导致测试失败,而模拟对象则可以。要辨别你是否使用了存根,最简单的方法是:存根永远不会导致测试失败,测试总是对被测试类进行断言。使用隔离(Mock)框架,测试代码会更加易读、易维护,重点是可以帮助我们节省不少时间编写模拟对象和存根。
参考资料
(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持续集成实践
【导读】我读经典,心旷神怡 - 经典书籍读后感汇总
【导读】我的诗和远方 - 也读唐诗与旅游游记汇总
点个【在看】如何?