写可測试的代码
寫可測試的代碼
不論什么一個軟件都是能夠測試。在某種意義上,用戶的使用過程也就是一個軟件測試的過程。但是這并非我們今天要講的可測試性。我們講的可測試性指的是代碼的可測試性,通俗點兒說就是是一串代碼里包括的邏輯是不是能夠被單元測試所覆蓋。在這篇文章里我會從單元測試的基本概念開始引伸到怎樣寫單元測試,怎樣寫可單元測試的代碼。文章里全部的樣例都是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(){// Arrangevar 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);// Actionpresenter.Greeting();// Assertview.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;// Constructorpublic 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(){// Arrangevar 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);// ActionuserMananger.RegisterUser("gaog","M","1988-08-08");// AssertdateTime.Verify();repository.Verify();}
碼十四
總結
在這篇文章里,我們提到了幾種設計模式,似乎非常牛氣。從前,我在參與技術討論的時候總是喜歡把設計模式掛在嘴邊。直到有一天,我突然意識到使用設計模式的目的并非讓自己的感覺有多良好,多牛氣。使用設計模式的目的是去解決一些實際的問題。添加代碼的可測試性也是我們在軟件開發過程中須要解決的問題之中的一個。毫無疑問,本文里提到的幾種設計模式能夠非常好地增強代碼的可測試性。但我們思維不應該被局限在這幾個模式的使用上面。在編寫代碼的時候我們應該多留一個心眼,先想想應該怎樣測試這段代碼。思想的翅膀把我們自然而然地引導到這些模式或很多其它更好的模式的應用其中。更進一步,也許我們在編寫代碼之前應該先把測試寫好?沒錯,這就是備受關注的測試驅動的開發方法。
總結
- 上一篇: Excel2007导入
- 下一篇: FreeBSD 9.1安装KMS