技术宅,fat-man

增加语言的了解程度可以避免写出愚蠢的代码

导航

写可测试的代码

http://blog.csdn.net/toy_shop_owner/article/details/25448849

任何一个软件都是可以测试。在某种意义上,用户的使用过程也就是一个软件测试的过程。可是这并不是我们今天要讲的可测试性。我们讲的可测试性指的是代码的可测试性,通俗点儿说就是是一串代码里包含的逻辑是不是可以被单元测试所覆盖。在这篇文章里我会从单元测试的基本概念开始引伸到如何写单元测试,如何写可单元测试的代码。文章里所有的例子都是C#写的,一来它是我职业生涯的主力语言。二来C#广为人知,相信对广大职业的或是业余的程序员来说读懂C#的代码不会是什么特别困难的事情。实际上我描述的方法和概念并不会局限于C#或是.Net框架。它们应该可以应用在其他平台,如Java的开发上。

值得一提的是在这篇文章里,我引用了不少参考文献。他们大体上都有比较权威的来源,或节选于知名网站如MSDN,或出至名家之手。这些参考文献都是很有意思的技术文章,都可以轻易在互联网上面找到,绝对值得一读。

单元测试是啥?

维基百科里对单元测试有一段及其拗口的定义,我试着翻译一下:

计算机程序里,单元测试是一个方法,一个可以配合可控的数据,使用流程或操作流程检测源代码,一个或多个软件模块的独立单元是否满足使用需求的方法

英文好的朋友可以看看原文,看看会不会有更好更深入的了解:

In computer programming, unit testing is a method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures are tested to determine if they are fit for use

一边翻译一边写,一边写一边读,舌头都要打结了。相比之下还是百度比较人性,它说:

单元测试是对最小可测试单元进行的检查和验证

MSDN的解释对单元测试的方法做了补充说明:

单元测试的目的就是从应用程序(application)抽取出最小的一块可测试的软件(software),把它与其他的代码分隔开来,然后判断它的行为是不是符合预期

单元测试必须是独立的,无牵无挂的,所以我们得想办法把要测试的软件单元和其他代码分隔开来。罗一(Roy Osherove)在他的书《单元测试的艺术The Art of Unit Testing)》里补充了单元测试另一个非常重要的属性:它得是自动的。

一个列子

 

自Visual Studio 2008开始,单元测试开始成为一个标准模版。创建单元测试的过程变得超级简单。这也间接地说明了单元测试在商业开发中的重要地位。微软MSDN上有一个详细的指导《Walkthrough: Creating and Running Unit Tests for Managed Code,一步一步地帮助你创建单元测试项目。如果你从来没有接触过单元测试,这是一个很好的开始。在这篇文章中,微软举了一个很有意思的例子(码一),值得我们细细分析。

   partial class HelloWorld : Page
   {
       protected void btnGreeting_Click(object sender, EventArgs e)
       {
           var stringBuilder = newStringBuilder();

           stringBuilder.Append("你好");

           stringBuilder.Append(txtName.Text);

           lblGreetingMessage.Text = stringBuilder.ToString();
       }
   }
码一

这个测试说的是一个人在银行账户里有11块9毛9时,他取了4块5毛5后账户里还应该有7块4毛4。这是一个典型的情景测试:我们假设了一个情景后测试逻辑运行的结果是否符合我们的预期。 眼尖的朋友或许已经注意到了,这个单元测试被分为个大块,Arrange,Act和Assert。引用这三块的第一个字母,我们可以说这个测试是AAA风格的测试。

第一个A(rrange)里,我们会准备好测试各方的关系:包括创建测试对象,设置测试的期待结果等等

第二个A(ct)往往很简单,我们得调用需要测试的函数

第三个A(ssert)最为关键,它描述了测试的目的和结果

AAA结构的测试代码在软件开发的队伍里是得到了广泛认可的。有些人甚至把AAA称为模式(pattern),很牛气。不论如何, AAA风格的测试代码条理清晰,通俗易懂,朗朗上口了。好东西人人喜欢,相信你也一样。

除了AAA外,上面这段小测试里还有一点值得大家注意的,就是它的命名方式。以下划线分隔这个测试函数的名称被分为三个部分:

<要测试的函数名称>_<测试所处在的情景>_<测试所预期的结果>

把测试对象和测试目的明确地写在测试函数名里是一个被广泛采用的好习惯。我们完全没有必要去担心它的名字有多长。我建议大家尽量地把测试函数名称写得更有描述性一些。 要知道,我们最有可能看到这个测试函数名称的时候往往就是这个测试挂掉的时候。而这个时候我更需要直观地知道挂掉的测试究竟是啥。

单元测试的条件

 

并不是所有的的代码都可以被单元测试的。 我曾受命重构一个基于Asp.net Web Forms框架的的网络应用。Web Forms是一个基于控件的,由事件驱动的网络应用框架。单元测试Web Forms的应用是一个很头疼的问题。下面是一小段典型的Web Forms的代码:

   partial class HelloWorld : Page
   {
       protected void btnGreeting_Click(object sender, EventArgs e)
       {
           var stringBuilder = newStringBuilder();

           stringBuilder.Append("你好");

           stringBuilder.Append(txtName.Text);

           lblGreetingMessage.Text = stringBuilder.ToString();
       }
   }
码二

这段代码告诉我们在一个网页里有一个叫txtName的文本框和一个叫btnGreeting的按钮。当用户在文本框里输入了自己的姓名后,点击,屏幕上会出现你好某某某字样。试想一下,我们应该如何为这个行为写单元测试呢?首先,我们应该构想一个测试情景:

“假如用户在文本框里输入刘德华,当他点击按钮是屏幕上会显示你好刘德华的字样”

可是我们如何才能把这个测试情景转化为测试代码呢?回顾码一,一个顺畅的单元测试需要我们:

  1. 设置被测试逻辑的输入;

  2. 运行被测试逻辑;

  3. 捕获输出。

对于码二而言,上述第一点和点三点似乎是两个不可逾越的障碍。一来我们无法控制txtName里的内容。二来我们也无法捕获码二的逻辑输出,也就是屏幕上要现实的内容。因此,我们说码二是不可测试的。

不可测试的代码并不代表着代码所包含的逻辑也是不可测试的。只是我们需要时时刻刻想着代码的可测试性,想着如何组织我们的代码结构才能满足单元测试的三个条件。

写可测试的代码

 

使用多层构架

 

写可测试的代码是一个综合能力。在InfoQ组织的虚拟座谈TDD有多美上,ThoughtWorks中国的熊节说测试就是设计。虽然他是针对测试驱动开发(TDD)说的,但写可测试的代码的确也体现了一个从微观到宏观,从细节到框架的设计能力。

合理的框架设计可以大大提高代码的可测试性。前文里提到Web Forms的测试是一个噩梦。因为WebForms的基本构架就像是一块铁板,很难能找到可以注入测试数据或者是提取结果的缝隙。 Dino Esposito同志在10年9月刊的MSDN杂志里发表了一篇名为《Better Web Forms with the MVP Pattern》的文章,描述了MVP的架构是如何把Web Forms拆分成三个互动的层,从而大大争强它的可测试性的。

图一

MVP的全称是Model-View-Presenter。图一描述了MVP的系统构架图。Model提高数据,View负责现实。而软件的主要的业务逻辑则封装在Presenter里面。按照MVP的原则重构了码二里描述的代码后(码三)。

   public partial classHelloWorldView : Page,IHelloWorldView
   {
       private readonly IHelloWorldViewPresenter _presenter;

       public HelloWorldView()
       {
           _presenter = new HelloWorldViewPresenter(this,newDateTimeWrapper());
       }

       public string Message
       {
           get
           {
               return lblGreetingMessage.Text;
           }

           set
           {
               lblGreetingMessage.Text = value;
           }
       }

       public string UserName
       {
           get
           {
               return txtName.Text;
           }
        
           set
           {
               txtName.Text = value;
           }
       }

       protected void btnGreeting_Click(object sender, EventArgs e)
       {
           _presenter.Greeting();
       }

   }

   protected void btnGreeting_Click(object sender, EventArgs e)
   {
        _presenter.Greeting();
   }

 

    public class HelloWorldViewPresenter : IHelloWorldViewPresenter
    {
       private readonly IHelloWorldView _view;

       public HelloWorldViewPresenter(IHelloWorldView view)
       {
           _view = view;
       }

       public void Greeting()
       {
           var stringBuilder = new StringBuilder();

           stringBuilder.Append("你好");
           stringBuilder.Append(_view.UserName);

           _view.Message = stringBuilder.ToString();
       }
   }

 

 
码三

 

关键的逻辑被分离出去到了presenter类后,测试变得如行云流水般的自然。多层构架的美妙之处是层与层之间没有紧密的联系。作为数据的提供者和结果的接受者,View可以很容易地被替身(Mock)取代。在单元测试的过程中替身的使用是很重要的。我们可以使用替身来控制输入和捕获输出。在网上使用Mock或者Stub来查找可以找到很多很有意思的文章和讨论。比如马丁(Martin Fowler)大叔的《Mocks aren’t Stubs就是讨论种种替身的一篇经典文章,不能不看。微软台湾MVP(不是MVP模式,Most Valuable Personnel)陈士杰的文章《Unit Test – Stub, Mock和Fake简介是为数不太多的中文文章。虽然我不是特别同意陈MVP在文章结尾关于Mock和Stub比例的说法,但仁者见仁智者见智,这篇文章依然是不错的参考。

码四告诉我们如何使用替身框架(Mocking Framework)Moq来注入测试数据并检验输出结果。Moq是.Net环境里应用很广泛的一个替身框架。在网上有不少Moq的使用例子和指南,感兴趣的朋友可以百度或google。

 

       [TestMethod]
       public void Greeting_WhenCalled_ShouldSetMessageToView()
       {
           // Arrange
           var view = new Mock<IHelloWorldView>();
 
           var expected = "你好刘德华";

           view.SetupGet(v => v.UserName).Returns("刘德华");

           view.SetupSet(v => v.Message = It.Is<string>(m => m == expected)).Verifiable();

           var presenter = new HelloWorldViewPresenter(view.Object);
           
           // Action
           presenter.Greeting();

           // Assert
           view.Verify();
       }
码四

 

对于很多软件开发员来说,Web Forms是一个该进博物馆的技术。但推动Web Forms向Asp.net + MVC (Model-View-Controller)的一个主要的力量就是软件的可测试性。MVC是一个和前面介绍的MVP非常接近的,多层结构的一个设计模式。与此相类似的还有被广泛应用去桌面开发的MVVM(Model-View-ViewModel)和它们的无数种变种。事实上多层结构对可测试性的提高不仅仅体现在宏观的框架上。比如,在具体实现当中,MVP或是MVC模式里的Presenter或者是Controller层都可以细分为更多的层结构。毫无疑问,这样的划分也可以让整个代码对测试更加友好。

 

写逻辑单纯的类和函数

 

几个月前,我为一个肿瘤专科医院做过一个项目,给他们的开发员讲解软件的测试性。一个开发员问如何测试一个包含了N个不同逻辑的方法。从定义来说,单元测试应该独立测试组成软件的最小的逻辑单元。所以从这一点来说我们应该有独立的,互不干涉地单元测试来测试组成这个方法的N个逻辑。但是独立地测试面条般重叠交错在一起的逻辑并不是一件容易的事情。所以对于这样的一个问题,真正彻底的答案应该是回过头去重新审视这个方法,看看有没有重构的可能。SOLID原则里的单一责任原则(Single Responsibility Principle)说一个类应该只为一个功能负责。同样,理想状况来说一个方法也不应该包含太多独立的逻辑。逻辑单纯的类和函数不但容易理解,容易维护也容易测试。

使用依赖注入(Dependency Injection或DI)   

 

单一责任原则的一个结果就是我们创建的类的数量会大大增加。这是个好事情,因为类的数量虽然是增加了,但类的体型会相对比较小,更容易理解和维护。类的数量多了,类与类直接的交互就变得频繁起来。这给单元测试制造了不小的麻烦,比如我们有一个类ClassA:

 

   public class ClassA : IClassA
   {
       public void Foo(string value)
       {
           var classB = new ClassB();

           classB.DoSomething(value);
       }
   }

码五

 

 

对于单元测试来说,Foo是一个挑战,因为我们很难把Foo的逻辑和classB.DoSomething(…)的逻辑分隔开来。对于Foo,一个完美的单元测试会试图去保证它调用了classB.DoSomething(…)。而DoSometing到底干了啥我们并不在乎。至少在对Foo的单元测试里我们不在乎。那么我们应该如何改进码五的可测试性呢?有两个方案:

       public void Foo(IClassB classB, string value)
       {
           classB.DoSomething(value);
       }

或是

       private readonly IClassB _classB;

       public ClassA(IClassB classB)
       {
           _classB = classB;
       }

       public void Foo(string value)
       {
           _classB.DoSomething(value);
       }

码六


图二显示了码六方案的类关系图。

 

图二

码六的实现方式常常被称为依赖注入,也就是大家常常能在英文的参考资料里看到的Dependency Injection。使用依赖注入可以有效低分离业务逻辑,增加可读性易于维护又不会形成过于紧密的依赖关系。如图二中Class A和Class B的联系仅仅一个interface来维系。Class A并不需要知道Class B的内容。这样的关系对于单元测试的实现来说是非常重要的,比如,在对 Class A进行的单元测试里我们可以使用替身来取代Class B(图三)。这样一方面的单元测试可以专注在Class A的逻辑上,另外一方面Class B的替身也可以为Class A提供必要的入参或捕获Class A的输出结果。

 

图三

 

码三举的例子是通过构建函数把界面IHelloWorldView注入到Presenter当中。这是一个应用相当广泛的方法。除了使用方便之外,在逻辑上也会更自然一些。说到使用方便,很多朋友或许会不以为然。在现实中,一个类需要注入的对象往往不会只有一个。而所注入的类往往也会有别的类注入其中。

var classA = new ClassA(new ClassB(new ClassD()),new ClassC(new ClassE()));
 

码七

码七里描述的情形虽然没有人愿意面对,但ClassA的确代表一段我们希望得到的高度可测的代码。难道没有什么方法可以两全其美吗?

使用DI容器

 

这个世界上两全奇美的事情并不太多,但的确有一个方法可以让在我们不增加使用复杂度的前提下增加代码的可测试性。这类方法统称DI容器,也叫IOC(Inverse of Control)容器。.Net环境里著名的DI容器有微软的Unity,Castle WindsorNinject感兴趣的朋友可以顺着下面的链接去研究研究。拿Unity来做例子,码五的代码可以简化为:

var classA = unityContainer.Resolve<IClassA>();

当然,在此之前,我们得把所有要用的类都登记在unityContainer中,如:

           unityContainer.RegisterType<IClassA,ClassA>();
           unityContainer.RegisterType<IClassB,ClassB>();
           unityContainer.RegisterType<IClassC,ClassC>();
           unityContainer.RegisterType<IClassD,ClassD>();
           unityContainer.RegisterType<IClassE,ClassE>();

码八

 

使用DI容器并不代表着一定要使用DI模式。事实上使用DI容器有两种常见的方法或者说模式,一种很有争议叫ServiceLocator模式,另一种基本没有争议的就是我们已经讨论过的依赖注入模式。

ServiceLocator模式

ServiceLocator也是一个设计模式,它因为马丁大叔的一篇文章《Inversion of Control Containers and the Dependency Injection Pattern而名声大噪。假如我们可以把一个软件中所有的类都归集到一本书里的话ServiceLocator就是这本书的目录。它可以告诉你如何去找到一个类,但却不能告诉你如果去创建这个类的实例。从功能上ServiceLocator和DI容器是绝配,因为DI容器知道如何去解释一个类已经所有它的依赖。Codeplex上有一个开源的项目CommonServiceLocator,支持包括Unity, Castle Windsor在内的9个DI容器。拿Unity为例子,使用CommonServiceLocator需要我们在软件运行的入口,比如Web应用里的Global.asax.cs设置好相应的定位器,如:

           var container = new UnityContainer();

           container.RegisterType<IClassA,ClassA>();      

           var provider = new UnityServiceLocator(container);
           ServiceLocator.SetLocatorProvider(() => provider);

码九

 

之后,在任何地方我们都可以召唤ServiceLocator来获得某个类的实例。ServiceLocator是可以单元测试的。没有什么能阻止我们在程序运行时改变ServiceLocator的定位器。配合Mock框架我们可以很容易地把一整套替身注入单元测试当中,如:

       [TestInitialize]
       public static void Initialize()
       {
           var classA = new Mock<IClassB>();
           var classB = new Mock<IClassC>();

           var container = new UnityContainer();

           container.RegisterInstance<IClassA>(classB.Object);
           container.RegisterInstance<IClassB>(classC.Object);

           var provider = new UnityServiceLocator(container);

           ServiceLocator.SetLocatorProvider(() => provider);
       }

码十

 

需要注意的是,ServiceLocator是静态类,如果不是专门设置,它的状态并不会随着单元测试而改变。为了保证每一个单元测试的独立性,我们应该保证每个单元测试之前ServiceLocator的定位器都应该回到初始的状态(码十)。

依赖注入模式是首选

ServiceLocator模式与依赖注入模式完全相悖的两个模式。使用ServiceLocator,任何依赖的对象都可以通过ServiceLocator.GetInstance()的方式获得。所以我们完全没有必要吧依赖对象通过构建函数或是别的方式注入。

但关于ServiceLocator的使用是有争议的,不少人认为它虽然在一定程度上提高了代码的可测试性,但同时也增加了对ServiceLocator的依赖。而且散布在各个角落的ServiceLocator.GetInstance(…)多多少少也影响了代码的整洁程度。如马克西门在他的博客里建议我们应该避免使用ServiceLocator。诚然,ServiceLocator的隐蔽性和它所产生的依赖性的确是会产生一些的问题,比如有时候忘记在ServiceLocator中注册一个类不会导致编译错误却会导致莫名其妙的异常中断等。

依赖注入配合Unity等DI容器应当是我们的首选。新的框架如Asp.Net MVC可以实现和Unity等DI容器的无缝连接。Code Project上有5星的文章《Microsoft Unity in ASP.NET MVC描述了Unity和Asp.Net MVC在依赖注入模式下的完美结合。这样一来我们不但可以和New()说bye bye,连ServiceLocator.GetInstance()都可以省掉。遗憾的是并不是每个框架都有Asp.Net MVC般的福利。有时,尤其是在重构陈年老码时ServiceLocator依然可以大显身手。只是我们在使用ServiceLocator的时候应该注意尽量减少对它的依赖和对GetInstance(…)的使用。记住:依赖注入模式应该优先于ServiceLocator模式。

使用包裹

 

有一天和一个程序员朋友聊天。他问我最讨厌.Net框架什么?我几乎是不加思索地说我最讨厌它可测试性。在.Net框架中有不少Static(Csharp里的static等同于VB.Net的shared)的类和函数。Static的类和函数是单元测试一大敌人。我们在写代码的时候应该尽量地避免Static的函数。

.Net里有不少static的类和函数还是我们会经常使用到的。举个例子,DateTime是最经常使用的类之一,我们经常会通过使用

 

DateTime.Now() 或是  DateTime.Today()

 

来获得当前的时间或日前。

 

       public long RegisterUser(string userName, string sex, string dob)
       {
           var createdAt = DateTime.UtcNow;

           return _userRepository.SaveUser(userName, sex, dob, createdAt);
       }

码十一

 

在码十一中,我们试图把一个用户信息写入数据库中。用户信息,如姓名性别等可以有外部,比如UI导入。但出于审计目的,我们想记录每一个记录的创建时间。创建记录时间的逻辑封装在函数RegisterUser里。毫无疑问,我们需要单元测试这一逻辑。But how?DateTime是静态类。这意味着我们没法使用替身代替它,意味着我们无法控制单元测试的输出。在这样的情况下,使用包裹大概是唯一可行的方案了。

       private readonly IUserRepository _userRepository;
       private readonly IDateTimeWrapper _dateTime;

       // Constructor
       public UserManagementService(
           IUserRepository userRepository,
           IDateTimeWrapper dateTime)
       {
           _userRepository = userRepository;
           _dateTime = dateTime;
       }

       public long RegisterUser(string userName, string sex, string dob)
       {
           var createdAt = _dateTime.UtcNow;

           return _userRepository.SaveUser(userName, sex, dob, createdAt);
       }

码十二

 

码十二的DateTimeWrapper就是DateTime的一个包裹。包裹里一对一地开发了我们会使用到的函数。它与DateTime最显著的区别有两个,其一:它不再是静态类;其二:它只包含我们需要使用的函数。在使用的时候,DateTimeWrapper可以通过依赖注入的方法注入到客户类当中,如码十三:

       [TestMethod]
       public void RegisterUser_RegisterAUser_ShouldCallShouldCallUtcNowOnDateTimeWrapper()
       {
           // Arrange
           var dateTime = new Mock<IDateTimeWrapper>();

           var repository = new Mock<IUserRepository>();

           var expected = newDateTime(1999, 9, 9);

           dateTime.SetupGet(t => t.UtcNow)
                   .Returns(expected)
                   .Verifiable();

           repository.Setup(r => r.SaveUser(It.IsAny<string>(),
                                            It.IsAny<string>(),
                                            It.IsAny<string>(),
                                            It.Is<DateTime>(dt => dt == expected)))
                     .Verifiable();

           var userMananger = new UserManagementService(repository.Object,
               dateTime.Object);

           // Action
           userMananger.RegisterUser("gaog","M","1988-08-08");

           // Assert
           dateTime.Verify();
           repository.Verify();
       }

码十三

 

这样一来对RegisterUser的单元测试就变得相当容易了(码十四):

       [TestMethod]
       public void RegisterUser_RegisterAUser_ShouldCallShouldCallUtcNowOnDateTimeWrapper()
       {
           // Arrange
           var dateTime = new Mock<IDateTimeWrapper>();

           var repository = new Mock<IUserRepository>();

           var expected = newDateTime(1999, 9, 9);

           dateTime.SetupGet(t => t.UtcNow)
                   .Returns(expected)
                   .Verifiable();

           repository.Setup(r => r.SaveUser(It.IsAny<string>(),
                                            It.IsAny<string>(),
                                            It.IsAny<string>(),
                                            It.Is<DateTime>(dt => dt == expected)))
                     .Verifiable();

           var userMananger = new UserManagementService(repository.Object,
               dateTime.Object);

           // Action
           userMananger.RegisterUser("gaog","M","1988-08-08");

           // Assert
           dateTime.Verify();
           repository.Verify();
       }

码十四

总结

在这篇文章了,我们提到了几种设计模式,似乎很牛气。从前,我在参与技术讨论的时候总是喜欢把设计模式挂在嘴边。直到有一天,我突然意识到使用设计模式的目的并不是让自己的感觉有多良好,多牛气。使用设计模式的目的是去解决一些实际的问题。增加代码的可测试性也是我们在软件开发过程中需要解决的问题之一。毫无疑问,本文里提到的几种设计模式可以很好地增强代码的可测试性。但我们思维不应该被局限在这几个模式的使用上面。在编写代码的时候我们应该多留一个心眼,先想想应该如何测试这段代码。思想的翅膀把我们自然而然地引导到这些模式或更多更好的模式的应用当中。更进一步,或许我们在编写代码之前应该先把测试写好?没错,这就是备受关注的测试驱动的开发方法。

 

posted on 2014-05-13 12:17  codestyle  阅读(755)  评论(0编辑  收藏  举报