上一篇我们学习了如何借助存根来破除依赖,这一篇我们来使用模拟对象进行交互测试。下一篇我们则会进一步使用隔离框架支持适应未来和可用性的功能。
被测试的工作单元(方法/函数)可能有三种最终结果,目前为止,我们编写过的测试只针对前两种:返回值和改变系统状态。现在,我们来了解如何测试第三种最终结果-调用第三方对象。.
模拟对象与存根的差别
模拟对象和存根之间的区别很小,但二者之间的区别非常微妙,但又很重要。二者最根本的区别在于:
存根不会导致测试失败,而模拟对象可以。
下图展示了存根和模拟对象之间的区别,可以看到测试会使用模拟对象验证测试是否失败:

第一个手工模拟对象
创建和使用模拟对象的方法与使用存根类似,只是模拟对象比存根多做一件事:它保存通讯的历史记录,这些记录之后用于预期(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:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定被测试的工作单元三种最终结果中的一个,不然的话天下大乱。
小结
本篇我们开始了单元测试核心技术之模拟对象的学习,通过使用模拟对象来进行交互测试。下一篇我们会进一步使用隔离/模拟框架来支持适应未来和可用性的功能。