一、伪对象
1、简单的业务场景
有一个文件监控程序,有一个方法用来检查文件名的合法性,检查过程中,如文件名不合法,需调用远程web服务记录日志,如远程web服务调用发生异常,发送邮件到指定收件人(类似这句话描述的业务场景在实际开发中数不甚数)。
2、什么是伪对象
上面所举的业务场景,文件监控主体程序是需要自己实现的,而远程web服务以及邮件服务都是监控程序所依赖的外部服务,在我们开发测试的时候可能还不能直接调用,或者调用服务代价太大(想想为什么代价比较大?),这个时候我们如何进行测试呢?
答案是构造伪对象(fake object)来代替外部依赖的服务,伪对象就是桩对象和模拟对象的统称。
在我们这个业务场景中,显然需要构造两个伪对象,即最常用的两个基础服务:日志和邮件服务。
(1)、桩对象(stub)
定义:桩对象是对系统中现有依赖项的一个替代品,可人为控制,通过使用桩对象,无需涉及依赖项,即可直接对代码进行测试。
和被测试对象的关系:
从上图可知,桩对象在单元测试中是不会被下断言的。
(2)、模拟对象(mock)
定义:模拟对象用来决定一个单元测试是通过还是失败。它通过验证被测试对象和伪对象之间是否进行预期的交互来判断。
和被测试对象的关系:
从上图可知,模拟对象在单元测试中必然要被下断言。
综上所述,我们可以分析在当前所举的场景中,如果不对web服务伪对象验证测试结果(即不对它进行断言),而只是用来确保测试正确运行,那么日志服务就是一个桩对象;如果我们需要针对邮件服务来做断言,验证它是否被正确调用,那么邮件服务就是一个模拟对象。
下面就手动创建两个伪对象,其中日志服务为桩对象,邮件服务为模拟对象,代码如下:
a、LogService
LogServiceusing System;
namespace MonitorService
{
public interface ILogService
{
Exception ExToThrow { get; set; }
/// <summary>
/// 记录日志
/// </summary>
/// <param name="msg"></param>
void AppendLog(string msg);
}
public class StubLogService : ILogService
{
public Exception ExToThrow { get; set; }
/// <summary>
/// 记录日志
/// </summary>
/// <param name="msg"></param>
public void AppendLog(string msg)
{
if (ExToThrow != null)
{
throw ExToThrow;
}
//throw new NotImplementedException("fake exception");
}
}
}
b、EmailService
EmailServicenamespace MonitorService
{
public interface IEmailService
{
string To { get; set; }
string Subject { get; set; }
string Body { get; set; }
/// <summary>
/// 发送邮件
/// </summary>
/// <param name="to"></param>
/// <param name="subject"></param>
/// <param name="body"></param>
void SendEmail(string to, string subject, string body);
}
public class MockEmailService : IEmailService
{
public string To { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public void SendEmail(string to, string subject, string body)
{
To = to;
Subject = subject;
Body = body;
}
}
}
在配置文件监控类的Analyze方法内部实现和桩对象和模拟对象的通信:
c、ConfigFileMonitor
ConfigFileMonitorusing System;
using MonitorService.Contract;
namespace MonitorService.Impl
{
/// <summary>
/// 配置文件监控管理类
/// </summary>
public class ConfigFileMonitor : IFileMonitor
{
public ILogService LogService { get; set; }
public IEmailService EmailService { get; set; }
private static readonly string configFileType = ".config";//配置文件后缀
public void Analyze(string fileName)
{
try
{
if (fileName.Length <= configFileType.Length)
{
LogService.AppendLog(string.Format("input filename({0}) is too short", fileName));
}
}
catch (Exception ex)
{
EmailService.SendEmail("jeffwong@cnblogs.com", "filename check", ex.Message);
}
}
}
}
单元测试如下:
d、UnitTest
MonitorServiceTest_Manualusing System;
using MonitorService;
using MonitorService.Impl;
using Ninject;
using NUnit.Framework;
namespace UnitTestApp
{
[TestFixture]
public class MonitorServiceTest_Manual
{
public ConfigFileMonitor CurrentFileMonitor
{
get;
set;
}
public ILogService LogService
{
get;
set;
}
public IEmailService EmailService
{
get;
set;
}
[SetUp]
public void SetUp()
{
using (var kernel = new StandardKernel(new ServiceModule()))
{
this.CurrentFileMonitor = kernel.Get<ConfigFileMonitor>();
this.CurrentFileMonitor.LogService = kernel.Get<StubLogService>();
this.CurrentFileMonitor.EmailService = kernel.Get<MockEmailService>();
}
}
[Test]
public void FileMonitor_Inject_GetInstance()
{
Assert.IsNotNull(CurrentFileMonitor, "ConfigFileMonitor is not initialized");
}
[Test]
public void FileMonitor_LogService_Inject_GetInstance()
{
Assert.IsNotNull(CurrentFileMonitor.LogService, "ConfigFileMonitor stub LogService is not initialized");
}
[Test]
public void FileMonitor_EmailService_Inject_GetInstance()
{
Assert.IsNotNull(CurrentFileMonitor.EmailService, "ConfigFileMonitor mock EmailService is not initialized");
}
[Test]
public void Analyze_WebServiceThrows_SendEmail()
{
CurrentFileMonitor.LogService.ExToThrow = new NotImplementedException("fake exception");
var shortFileName = "abc.txt";
CurrentFileMonitor.Analyze(shortFileName);
Assert.AreEqual("jeffwong@cnblogs.com", CurrentFileMonitor.EmailService.To);
Assert.AreEqual("filename check", CurrentFileMonitor.EmailService.Subject);
Assert.AreEqual("fake exception", CurrentFileMonitor.EmailService.Body);
//Assert.AreEqual("fake object", CurrentFileMonitor.EmailService.Body);
}
[TearDown]
public void TearDown()
{
this.CurrentFileMonitor = null;
}
}
}
其中,服务调用我们通过Ninject实现解耦,为了简单看到效果,这里没有直接通过构造函数或者属性实现依赖注入。
通过上面的说明我们确实可以得出这两个伪对象是有明显区别的,某些书洋洋洒洒毫不吝惜笔墨写了许多页,就专门介绍这两三个概念的异同,但是看的人依然云里雾里。
二、隔离框架
1、手写模拟对象和桩对象的问题
写单元测试的时候,如果不借助框架和工具,有一部分时间必然花在构造模拟对象和桩对象上,不是说这样的工作没有意义,主要是哼哧哼哧写这么多代码显而易见会导致如下几个问题:
(1)、又要写单元测试代码,还要造N个伪对象?我TMD又不是苦力,写不过来啊写不过来;
(2)、有些类非常坑爹,一堆方法、事件和属性,除此之外,还有很多私有方法,写不过来啊写不过来;
(3)、某些方法参数对象属性很多,不知要写多少断言才能验证结束,写不过来啊写不过来;
(4)、如果模拟的方法被多次调用,一旦涉及状态保存问题,必须在内部写很多代码,写不过来啊写不过来;
(5)、写了一个模拟对象和桩对象,换到另一个测试中又TMD需要写一遍,写不过来啊写不过来。
大家都知道一个道理,人最不可靠,写的越多出现问题的概率越大,而且免不了要写很多相同或相似的代码我们自然会想到自动化,相信以开发人员的智慧,应该都会有这个觉悟,而且无数实践证明写程序当然高度自动化最好。
你要让程序员手动去写所有单元测试,好吧,有人肯定会先上网看看新闻渴了倒杯水顺便看看心仪的美女不渴也要去看美女一定要看到看不到就会想什么时候能来然后再去看...
单元测试很重要,但是没有多少人愿意投入精力去写,原因无它,唯懒而已。
2、使用隔离框架
还是根据一中的示例代码,我们采用隔离框架Rhino Mocks(历史悠久,资料丰富),看看如何通过隔离框架自动生成桩对象和模拟对象进行单元测试:
(1)构造桩对象LogService
StubLogService var mock = new Rhino.Mocks.MockRepository();
var logService = mock.Stub<ILogService>();
CurrentFileMonitor.LogService = logService;
Assert.IsNotNull(CurrentFileMonitor.LogService, "ConfigFileMonitor stub LogService is not initialized");
(2)构造模拟对象EmailService
MockEmailService var mock = new Rhino.Mocks.MockRepository();
var emailService = mock.DynamicMock<IEmailService>();
CurrentFileMonitor.EmailService = emailService;
Assert.IsNotNull(CurrentFileMonitor.EmailService, "ConfigFileMonitor mock EmailService is not initialized");
(3)单元测试
a、经典的录制-回放模型
RhinoMocksTest_RecordPlayback /// <summary>
/// 使用录制-回放模型测试示例
/// </summary>
[Test]
public void RhinoMocks_Analyze_WebServiceThrows_SendEmail()
{
var mock = new Rhino.Mocks.MockRepository();
var logService = mock.Stub<ILogService>();
var emailService = mock.DynamicMock<IEmailService>();
using (mock.Record())
{
logService.AppendLog("input filename(abc.txt) is too short");
LastCall.Throw(new NotImplementedException("fake exception")); //记录指定日志,抛出异常
}
CurrentFileMonitor.LogService = logService;
CurrentFileMonitor.EmailService = emailService;
var shortFileName = "abc.txt";
CurrentFileMonitor.Analyze(shortFileName);
mock.Verify(emailService);//VerifyAll()或者Verify(stub)不会对桩对象做任何断言,它们只适用于模拟对象
}
b、设置-操作-断言(AAA)模型
RhinoMocksTest_AAA /// <summary>
/// 设置-操作-断言模型测试示例 (Arrange-Act-Assert AAA)
/// </summary>
[Test]
public void RhinoMocks_Analyze_WebServiceThrows_SendEmail_AAA()
{
var mock = new Rhino.Mocks.MockRepository();
var logService = mock.Stub<ILogService>();
logService.Expect(svc => svc.AppendLog("input filename(abc.txt) is too short"))
.Throw(new NotImplementedException("fake exception")); //记录指定日志,抛出异常
var emailService = mock.DynamicMock<IEmailService>();
CurrentFileMonitor.LogService = logService;
CurrentFileMonitor.EmailService = emailService;
mock.ReplayAll(); //移到操作模式
var shortFileName = "abc.txt";
CurrentFileMonitor.Analyze(shortFileName);
//使用Rhino Mocks来断言
emailService.AssertWasCalled(m => m.SendEmail("jeffwong@cnblogs.com", "filename check", "fake exception"));//成功
//emailService.AssertWasCalled(m => m.SendEmail("it@cnblogs.com", "filename check", "fake exception"));//失败
}
不论是经典的录制-回放模式还是AAA(Arrange-Act-Assert,即设置-操作-断言)语法,使用Rhino Mocks生成伪对象进行测试可读性都是比较强的。目前可能AAA模式更加流行,写起来也确实简洁不少。本文示例只是小试牛刀,感兴趣可以下载本文demo试验一下。
其他比较成熟的隔离框架还有Moq,Typemock Isolator,EasyMock.NET,NMock,NUnit.Mocks等等。目前Moq以其简洁方便无痛的学习曲线而迅速崛起,参考了一些资料,发现它的某些写法确实比Rhino Mocks简单直接可读性更强,尤其Moq天然的AAA写法,Rhino Mocks正是借鉴它而加入AAA,关于Moq的使用大家可以参考官方文档。
最后这里还是要提一下,示例中给出的代码都非常简单,而实际开发中进行单元测试远比理论中来的困难和复杂,比如文档很少或没有文档被人遗忘的历史遗留系统,项目中包含错综复杂的业务逻辑,生僻的应用系统领域知识以及频繁打补丁的需求变更等等,这样合理利用框架和工具写出优秀的单元测试尤其重要,否则维护单元测试的代码也是一个极大的负担。
demo下载:UnitTestApp
参考:
http://code.google.com/p/moq/
http://hibernatingrhinos.com/open-source/rhino-mocks
http://www.codeproject.com/Articles/10870/New-Version-Of-Rhino-Mocks
<<C#测试驱动开发>>