C#单元测试的使用( 二)

上一篇我们学习了如何借助存根来破除依赖,这一篇我们来使用模拟对象进行交互测试。下一篇我们则会进一步使用隔离框架支持适应未来和可用性的功能。

被测试的工作单元(方法/函数)可能有三种最终结果,目前为止,我们编写过的测试只针对前两种:返回值和改变系统状态。现在,我们来了解如何测试第三种最终结果-调用第三方对象.

模拟对象与存根的差别  

 

模拟对象和存根之间的区别很小,但二者之间的区别非常微妙,但又很重要。二者最根本的区别在于:

存根不会导致测试失败,而模拟对象可以。

下图展示了存根和模拟对象之间的区别,可以看到测试会使用模拟对象验证测试是否失败:

C#单元测试的使用( 二)

第一个手工模拟对象  

创建和使用模拟对象的方法与使用存根类似,只是模拟对象比存根多做一件事:它保存通讯的历史记录,这些记录之后用于预期(Expection)验证。

假设我们的被测试项目LogAnalyzer需要和一个外部的Web Service交互,每次LogAnalyzer遇到一个过短的文件名,这个Web Service就会收到一个错误消息。遗憾的是,要测试的这个Web Service还没有完全实现。就算实现了,使用这个Web Service也会导致测试时间过长。

因此,我们需要重构设计,创建一个新的接口,之后用于这个接口创建模拟对象。这个接口只包括我们需要调用的Web Service方法。

C#单元测试的使用( 二)

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之间的交互。

C#单元测试的使用( 二)

同时使用模拟对象与存根  

假设我们得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与其他对象的交互。

C#单元测试的使用( 二)

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);    }

C#单元测试的使用( 二)

Note:每个测试应该只测试一件事情,测试中应该也最多只有一个模拟对象。一个测试只能指定被测试的工作单元三种最终结果中的一个,不然的话天下大乱。

小结  

本篇我们开始了单元测试核心技术之模拟对象的学习,通过使用模拟对象来进行交互测试。下一篇我们会进一步使用隔离/模拟框架来支持适应未来和可用性的功能。