写可測试的代码
不论什么一个软件都是能够測试。在某种意义上,用户的使用过程也就是一个软件測试的过程。但是这并非我们今天要讲的可測试性。我们讲的可測试性指的是代码的可測试性,通俗点儿说就是是一串代码里包括的逻辑是不是能够被单元測试所覆盖。在这篇文章里我会从单元測试的基本概念開始引伸到怎样写单元測试,怎样写可单元測试的代码。文章里全部的样例都是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》,一步一步地帮助你创建单元測试项目。假设你从来没有接触过单元測试,这是一个非常好的開始。在这篇文章中,微软举了一个非常有意思的样例(码一),值得我们细细分析。
[TestMethod] public void Debit_WithValidAmount_UpdatesBalance() { // arrange double beginningBalance = 11.99; double debitAmount = 4.55; double expected = 7.44; BankAccount account =new BankAccount("Mr. Bryan Walton", beginningBalance); // act account.Debit(debitAmount); // assert double actual = account.Balance; Assert.AreEqual(expected, actual, 0.001,"Account not debited correctly"); }
这个測试说的是一个人在银行账户里有11块9毛9时,他取了4块5毛5后账户里还应该有7块4毛4。这是一个典型的情景測试:我们如果了一个情景后測试逻辑执行的结果是否符合我们的预期。 眼尖的朋友也许已经注意到了,这个单元測试被分为个大块,Arrange,Act和Assert。引用这三块的第一个字母,我们能够说这个測试是AAA风格的測试。
第一个A(rrange)里,我们会准备好測试各方的关系:包含创建測试对象,设置測试的期待结果等等
第二个A(ct)往往非常easy,我们得调用须要測试的函数
第三个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的button。当用户在文本框里输入了自己的姓名后,点击,屏幕上会出现你好某某某字样。试想一下,我们应该怎样为这个行为写单元測试呢?首先,我们应该构想一个測试情景:
“假如用户在文本框里输入刘德华,当他点击button是屏幕上会显示你好刘德华的字样”
但是我们怎样才干把这个測试情景转化为測试代码呢?回想码一,一个顺畅的单元測试须要我们:
-
设置被測试逻辑的输入;
-
执行被測试逻辑;
-
捕获输出。
对于码二而言,上述第一点和点三点似乎是两个不可逾越的障碍。一来我们无法控制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的原则重构了码二里描写叙述的代码后(码三)。
View:
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(); }
Presenter:
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能够非常easy地被替身(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个逻辑。可是独立地測试面条般重叠交错在一起的逻辑并非一件easy的事情。所以对于这种一个问题,真正彻底的答案应该是回过头去又一次审视这种方法,看看有没有重构的可能。SOLID原则里的单一责任原则(Single Responsibility Principle)说一个类应该仅仅为一个功能负责。相同,理想状况来说一个方法也不应该包括太多独立的逻辑。逻辑单纯的类和函数不但easy理解,easy维护也easy測试。
使用依赖注入(Dependency Injection或DI)
单一责任原则的一个结果就是我们创建的类的数量会大大添加。这是个好事情,由于类的数量尽管是添加了,但类的体型会相对照较小,更easy理解和维护。类的数量多了,类与类直接的交互就变得频繁起来。这给单元測试制造了不小的麻烦,比方我们有一个类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 Windsor和Ninject感兴趣的朋友能够顺着以下的链接去研究研究。拿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框架我们能够非常easy地把一整套替身注入单元測试其中,如:
[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是静态类。这意味着我们没法使用替身取代它,意味着我们无法控制单元測试的输出。在这种情况下,使用包裹大概是唯一可行的方案了。
public class DateTimeWrapper : IDateTimeWrapper { public DateTime UtcNow { get { returnDateTime.UtcNow; } } }
码十二
码十二的DateTimeWrapper就是DateTime的一个包裹。包裹里一对一地开发了我们会使用到的函数。它与DateTime最显著的差别有两个,其一:它不再是静态类;其二:它仅仅包括我们须要使用的函数。在使用的时候,DateTimeWrapper能够通过依赖注入的方法注入到客户类其中,如码十三:
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); }
码十三
这样一来对RegisterUser的单元測试就变得相当easy了(码十四):
[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(); }
码十四
总结
在这篇文章里,我们提到了几种设计模式,似乎非常牛气。从前,我在參与技术讨论的时候总是喜欢把设计模式挂在嘴边。直到有一天,我突然意识到使用设计模式的目的并非让自己的感觉有多良好,多牛气。使用设计模式的目的是去解决一些实际的问题。添加代码的可測试性也是我们在软件开发过程中须要解决的问题之中的一个。毫无疑问,本文里提到的几种设计模式能够非常好地增强代码的可測试性。但我们思维不应该被局限在这几个模式的使用上面。在编写代码的时候我们应该多留一个心眼,先想想应该怎样測试这段代码。思想的翅膀把我们自然而然地引导到这些模式或很多其它更好的模式的应用其中。更进一步,也许我们在编写代码之前应该先把測试写好?没错,这就是备受关注的測试驱动的开发方法。