.NET单元测试的艺术-2.核心技术

开篇:上一篇咱们学习基本的单元测试基础知识和入门实例。可是,若是咱们要测试的方法依赖于一个外部资源,如文件系统、数据库、Web服务或者其余难以控制的东西,那又该如何编写测试呢?为了解决这些问题,咱们须要建立测试存根伪对象模拟对象。这一篇中咱们会开始接触这些核心技术,借助存根破除依赖,使用模拟对象进行交互测试,使用隔离框架支持适应将来和可用性的功能。html

系列目录:

1.入门git

2.核心技术github

3.测试代码web

1、破除依赖-存根

1.1 为什么使用存根?

  当咱们要测试的对象依赖另外一个你没法控制(或者还未实现)的对象,这个对象多是Web服务、系统时间、线程调度或者不少其余东西。数据库

  那么重要的问题来了:你的测试代码不能控制这个依赖的对象向你的代码返回什么值,也不能控制它的行为(例如你想摸你一个异常)。编程

  所以,这种状况下你可使用存根框架

1.2 存根简介

  (1)外部依赖项ide

一个外部依赖项是系统中的一个对象,被测试代码与这个对象发生交互,但你不能控制这个对象。(常见的外部依赖项包括:文件系统、线程、内存以及时间等)函数

  (2)存根单元测试

一个存根(Stub)是对系统中存在的一个依赖项(或者协做者)的可控制的替代物。经过使用存根,你在测试代码时无需直接处理这个依赖项。

1.3 发现项目中的外部依赖

  继续上一篇中的LogAn案例,假设咱们的IsValidLogFilename方法会首先读取配置文件,若是配置文件说支持这个扩展名,就返回true:

    public bool IsValidLogFileName(string fileName)
    {
        // 读取配置文件
        // 若是配置文件说支持这个扩展名,则返回true
    }

  那么问题来了:一旦测试依赖于文件系统,咱们进行的就是集成测试,会带来全部与集成测试相关的问题—运行速度较慢,须要配置,一次测试多个内容等。

  换句话说,尽管代码自己的逻辑是彻底正确的,可是这种依赖可能致使测试失败。

1.4 避免项目中的直接依赖

  想要破除直接依赖,能够参考如下两个步骤:

  (1)找到被测试对象使用的外部接口或者API;

  (2)把这个接口的底层实现替换成你能控制的东西;

  对于咱们的LogAn项目,咱们要作到替代实例不会访问文件系统,这样便破除了文件系统的依赖性。所以,咱们能够引入一个间接层来避免对文件系统的直接依赖。访问文件系统的代码被隔离在一个FileExtensionManager类中,这个类以后将会被一个存根类替代,以下图所示:

  在上图中,咱们引入了存根 ExtensionManagerStub 破除依赖,如今咱们得代码不该该知道也不会关心它使用的扩展管理器的内部实现。

1.5 重构代码提升可测试性

  有两类打破依赖的重构方法,两者相互依赖,他们被称为A型和B型重构。

  (1)A型 把具体类抽象成接口或委托;

  下面咱们实践抽取接口将底层实现变为可替换的,继续上述的IsValidLogFileName方法。

  Step1.咱们将和文件系统打交道的代码分离到一个单独的类中,以便未来在代码中替换带对这个类的调用。

  ①使用抽取出的类

    public bool IsValidLogFileName(string fileName)
    {
        FileExtensionManager manager = new FileExtensionManager();
        return manager.IsValid(fileName);
    }
View Code

  ②定义抽取出的类

    public class FileExtensionManager : IExtensionManager
    {
        public bool IsValid(string fileName)
        {
            bool result = false;
            // 读取文件

            return result;
        }
    }
View Code

  Step2.而后咱们从一个已知的类FileExtensionManager抽取出一个接口IExtensionManager。

    public interface IExtensionManager
    {
        bool IsValid(string fileName);
    }
View Code

  Step3.建立一个实现IExtensionManager接口的简单存根代码做为可替换的底层实现。

    public class AlwaysValidFakeExtensionManager : IExtensionManager
    {
        public bool IsValid(string fileName)
        {
            return true;
        }
    }
View Code

  因而,IsValidLogFileName方法就能够进行重构了:

    public bool IsValidLogFileName(string fileName)
    {
        IExtensionManager manager = new FileExtensionManager();
        return manager.IsValid(fileName);
    }
View Code

  可是,这里被测试方法仍是对具体类进行直接调用,咱们必须想办法让测试方法调用伪对象而不是IExtensionManager的本来实现,因而咱们想到了DI(依赖注入),这时就须要B型重构。

  (2)B型 重构代码,从而可以对其注入这种委托和接口的伪实现。

  刚刚咱们想到了依赖注入,依赖注入的主要表现形式就是构造函数注入与属性注入,因而这里咱们主要来看看构造函数层次与属性层次如何注入一个伪对象。

  ① 经过构造函数注入伪对象

  根据上图所示的流程,咱们能够重构LogAnalyzer代码:

    public class LogAnalyzer
    {
        private IExtensionManager manager;

        public LogAnalyzer(IExtensionManager manager)
        {
            this.manager = manager;
        }

        public bool IsValidLogFileName(string fileName)
        {
            return manager.IsValid(fileName);
        }
    }
View Code

  其次,再添加新的测试代码:

    [TestFixture]
    public class LogAnalyzerTests
    {
        [Test]
        public void IsValidFileName_NameSupportExtension_ReturnsTrue()
        {
            // 准备一个返回true的存根
            FakeExtensionManager myFakeManager = new FakeExtensionManager();
            myFakeManager.WillBeValid = true;
            // 经过构造器注入传入存根
            LogAnalyzer analyzer = new LogAnalyzer(myFakeManager);
            bool result = analyzer.IsValidLogFileName("short.ext");

            Assert.AreEqual(true, result);
        }

        // 定义一个最简单的存根
        internal class FakeExtensionManager : IExtensionManager
        {
            public bool WillBeValid = false;
            public bool IsValid(string fileName)
            {
                return WillBeValid;
            }
        }
    }    
View Code

Note:这里将伪存根类和测试代码放在一个文件里,由于目前这个伪对象只在这个测试类内部使用。它比起手工实现的伪对象和测试代码放在不一样文件中,将它们放在一个文件里的话,定位、阅读以及维护代码都要容易的多。  

  ② 经过属性设置注入伪对象

  构造函数注入只是方法之一,属性也常常用来实现依赖注入。

  根据上图所示的流程,咱们能够重构LogAnalyzer类:

    public class LogAnalyzer
    {
        private IExtensionManager manager;

        // 容许经过属性设置依赖项
        public IExtensionManager ExtensionManager
        {
            get
            {
                return manager;
            }

            set
            {
                manager = value;
            }
        }

        public LogAnalyzer()
        {
            this.manager = new FileExtensionManager();
        }

        public bool IsValidLogFileName(string fileName)
        {
            return manager.IsValid(fileName);
        }
    }
View Code

  其次,新增一个测试方法,改成属性注入方式:

    [Test]
    public void IsValidFileName_SupportExtension_ReturnsTrue()
    {
        // 设置要使用的存根,确保其返回true
        FakeExtensionManager myFakeManager = new FakeExtensionManager();
        myFakeManager.WillBeValid = true;
        // 建立analyzer,注入存根
        LogAnalyzer log = new LogAnalyzer();
        log.ExtensionManager = myFakeManager;
        bool result = log.IsValidLogFileName("short.ext");

        Assert.AreEqual(true, result);
    }
View Code

Note : 若是你想代表被测试类的某个依赖项是可选的,或者测试能够放心使用默认建立的这个依赖项实例,这时你就可使用属性注入。

1.6 抽取和重写

  抽取和重写是一项强大的技术,可直接替换依赖项,实现起来快速干净,可让咱们编写更少的接口、更多的虚函数。

  仍是继续上面的例子,首先改造被测试类(位于Manulife.LogAn),添加一个返回真实实例的虚工厂方法,正常在代码中使用工厂方法:

    public class LogAnalyzerUsingFactoryMethod
    {
        public bool IsValidLogFileName(string fileName)
        {
            // use virtual method
            return GetManager().IsValid(fileName);
        }

        protected virtual IExtensionManager GetManager()
        {
            // hard code
            return new FileExtensionManager();
        }
    }
View Code

  其次,在改造测试项目(位于Manulife.LogAn.UnitTests),建立一个新类,声明这个新类继承自被测试类,建立一个咱们要替换的接口(IExtensionManager)类型的公共字段(不须要属性get和set方法):

    public class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
    {
        public IExtensionManager manager;

        public TestableLogAnalyzer(IExtensionManager manager)
        {
            this.manager = manager;
        }

        // 返回你指定的值
        protected override IExtensionManager GetManager()
        {
            return this.manager;
        }
    }
View Code

  最后,改造测试代码,这里咱们建立的是新派生类而非被测试类的实例,配置这个新实例的公共字段,设置成咱们在测试中建立的存根实例FakeExtensionManager:

    [Test]
    public void OverrideTest()
    {
        FakeExtensionManager stub = new FakeExtensionManager();
        stub.WillBeValid = true;
        // 建立被测试类的派生类的实例
        TestableLogAnalyzer logan = new TestableLogAnalyzer(stub);
        bool result = logan.IsValidLogFileName("stubfile.ext");

        Assert.AreEqual(true, result);
    }
View Code

2、交互测试-模拟对象

  工做单元可能有三种最终结果,目前为止,咱们编写过的测试只针对前两种:返回值和改变系统状态。如今,咱们来了解如何测试第三种最终结果-调用第三方对象。

2.1 模拟对象与存根的区别

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

存根不会致使测试失败,而模拟对象能够

  下图展现了存根和模拟对象之间的区别,能够看到测试会使用模拟对象验证测试是否失败。

2.2 第一个手工模拟对象

  建立和使用模拟对象的方法与使用存根相似,只是模拟对象比存根多作一件事:它保存通信的历史记录,这些记录以后用于预期(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;
        }
    }
View Code

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

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

  能够看出,这里的测试代码中咱们是对模拟对象进行断言,而非LogAnalyzer类,由于咱们测试的是LogAnalyzer和Web Service之间的交互

2.3 同时使用模拟对象和存根

  假设咱们得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);
        }
    }
View Code

  能够看出,这里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;
        }
    }
View Code

  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;
        }
    }
View Code

  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;
            }
        }
    }
View Code

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

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

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

3、隔离(模拟)框架

3.1 为什么使用隔离框架

  对于复杂的交互场景,可能手工编写模拟对象和存根就会变得很不方便,所以,咱们能够借助隔离框架来帮咱们在运行时自动生成存根和模拟对象。

一个隔离框架是一套可编程的API,使用这套API建立伪对象比手工编写容易得多,快得多,并且简洁得多。

  隔离框架的主要功能就在于帮咱们生成动态伪对象,动态伪对象是运行时建立的任何存根或者模拟对象,它的建立不须要手工编写代码(硬编码)。

3.2 关于NSubstitute隔离框架

  Nsubstitute是一个开源的框架,源码是C#实现的。你能够在这里得到它的源码:https://github.com/nsubstitute/NSubstitute

  NSubstitute 更注重替代(Substitute)概念。它的设计目标是提供一个优秀的测试替代的.NET模拟框架。它是一个模拟测试框架,用最简洁的语法,使得咱们可以把更多的注意力放在测试工做,减轻咱们的测试配置工做,以知足咱们的测试需求,帮助完成测试工做。它提供最常常须要使用的测试功能,且易于使用,语句更符合天然语言,可读性更高。对于单元测试的新手或只专一于测试的开发人员,它具备简单、友好的语法,使用更少的lambda表达式来编写完美的测试程序。

  NSubstitute 采用的是Arrange-Act-Assert测试模式,你只须要告诉它应该如何工做,而后断言你所指望接收到的请求,就大功告成了。由于你有更重要的代码要编写,而不是去考虑是须要一个Mock仍是一个Stub。

  在.NET项目中,咱们仍然能够经过NuGet来安装NSubsititute:

3.3 使用NSubstitute模拟对象

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

  如今咱们看看如何使用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");
    }
View Code

  须要注意的是:

  (1)ILogger接口自身并无这个Received方法;

  (2)NSub命名空间提供了一个扩展方法Received,这个方法能够断言在测试中调用了伪对象的某个方法;

  (3)经过在LogError()前调用Received(),实际上是NSub在询问伪对象的这个方法是否调用过。

3.4 使用NSubstitute模拟值

  若是接口的方法返回不为空,如何从实现接口的动态伪对象返回一个值呢?咱们能够借助NSub强制方法返回一个值:

    [Test]
    public void Returns_ByDefault_WorksForHardCodeArgument()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
        // 强制方法返回假值
        fakeRules.IsValidLogFileName("strict.txt").Returns(true);

        Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));
    }
View Code

  若是咱们不想关心方法的参数,即不管参数是什么,方法应该老是返回一个价值,这样的话测试会更容易维护,所以咱们能够借助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"));
    }
View Code

  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"));
    }
View Code

  这里,使用了Assert.Throws验证被测试方法确实抛出了一个异常。When和Do两个方法顾名思义表明了何时发生了什么事,发生了事以后要触发其余什么事。须要注意的是,这里When方法必须使用Lambda表达式。

3.5 同时使用模拟对象和存根

  这里咱们在一个场景中结合使用两种类型的伪对象:一个用做存根,另外一个用做模拟对象。

  继续前面的一个例子,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);
                }
            }
        }
    }
View Code

  如今咱们借助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")));
    }
View Code

  这里咱们不须要手工实现伪对象,可是代码的可读性已经变差了,由于有一堆Lambda表达式,不过它也帮咱们避免了在测试中使用方法名字符串。

4、小结

  本篇咱们学习了单元测试的核心技术:存根、模拟对象以及隔离框架。使用存根能够帮助咱们破除依赖,模拟对象与存根的区别主要在于存根不会致使测试失败,而模拟对象则能够。要辨别你是否使用了存根,最简单的方法是:存根永远不会致使测试失败,测试老是对被测试类进行断言。使用隔离框架,测试代码会更加易读、易维护,重点是能够帮助咱们节省很多时间编写模拟对象和存根。

参考资料

      The Art of Unit Testing

  (1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》

  (2)匠心十年,《NSubsititue彻底手册

  (3)张善友,《单元测试模拟框架:NSubstitute

 

相关文章
相关标签/搜索