Photo :Unit Test in Visual Studio
文 | Edison Zhou
上一篇我们学习了如何借助存根来破除依赖,这一篇我们来使用模拟对象进行交互测试。下一篇我们则会进一步使用隔离框架支持适应未来和可用性的功能。
被测试的工作单元(方法/函数)可能有三种最终结果,目前为止,我们编写过的测试只针对前两种:返回值和改变系统状态。现在,我们来了解如何测试第三种最终结果-调用第三方对象。
模拟对象与存根的差别
模拟对象和存根之间的区别很小,但二者之间的区别非常微妙,但又很重要。二者最根本的区别在于:
存根不会导致测试失败,而模拟对象可以。
下图展示了存根和模拟对象之间的区别,可以看到测试会使用模拟对象验证测试是否失败:
第一个手工模拟对象
创建和使用模拟对象的方法与使用存根类似,只是模拟对象比存根多做一件事:它保存通讯的历史记录,这些记录之后用于预期(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之间的交互。
同时使用模拟对象与存根
假设我们得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);
}
Note:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定被测试的工作单元三种最终结果中的一个,不然的话天下大乱。
小结
本篇我们开始了单元测试核心技术之模拟对象的学习,通过使用模拟对象来进行交互测试。下一篇我们会进一步使用隔离/模拟框架来支持适应未来和可用性的功能。
参考资料
(1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》
(2)匠心十年,《NSubsititue完全手册》
(3)张善友,《单元测试模拟框架:NSubstitute》
2020后记:虽然这是一篇发表于2015年的文章,但我至今觉得仍有价值。因为我发现在.NET圈,还是有很多童鞋不了解单元测试和不喜欢写单元测试,不懂其价值就不会形成增强回路。所谓增强回路,就是我单元测试写的越多,以后修改代码增加功能就不容易出现Bug(这里主要指SIT阶段、UAT阶段乃至线上),越不容易出现Bug我提交的代码质量就越高,就会增强我写单元测试的愿望,形成一个回路。在我现在的实践中,是把单元测试加入了持续集成构建任务中的,每次组员提交代码都会触发构建任务,去编译项目,去跑单元测试,只要单元测试没有跑过就会邮件或者通知发出来告诉我,我会知道是谁提交的代码居然没有跑单元测试就提交了,我就会找他改Bug了,呵呵。