Apworks框架實戰(三):單元測試與持續整合

dax.net發表於2014-04-14

雖然這部分內容並沒有過多地討論Apworks框架的使用,但這部分內容非常重要,它與Apworks框架本身的設計緊密相關,也是進一步瞭解Apworks框架設計的必修課。

單元測試與持續整合概述

在敏捷開發過程中,單元測試是非常重要的。這不同於傳統的瀑布開發模型,在瀑布模型中,單元測試的重要性體現的並不明顯,因為在這種模型中,“測試”被強調為整個開發流程中的一個環節,也會有專門的測試團隊來負責測試過程,於是,由開發人員負責的單元測試往往被忽略。另一方面,在專案剛剛開始時,由於團隊對開發過程和規範的重視,開發人員會著手一部分單元測試的編寫工作,但隨著時間的推移、需求的變化以及專案進度的推進,出於專案上線或者產品釋出的壓力,開發團隊逐漸更多地關注功能的實現和缺陷(Bug)修復,單元測試也就隨手扔在一邊。最終,我們能夠看到的結果就是,絕大多數專案中都包含了單元測試的工程,但這些單元測試的工程又往往都是被遺棄已久,甚至是無法編譯的。

然而,在實踐敏捷開發的專案中,單元測試是非常重要的,這在文章的第一部分就做了簡單的介紹。這種重要性源自於敏捷開發過程的基本特性:以持續整合的方式應對不斷變化的客戶需求。軟體系統開發的一個重要特點就是需求的不確定性和可變性,傳統的瀑布模型以按部就班的方式開發軟體系統,很明顯無法有效地應對這種可變性特點。敏捷開發過程以迭代的方式進行,專案負責人(Product Owner)將總體需求分割成多個使用者故事(User Story),並根據重要優先順序,在產品功能特性列表(Product Backlog)中對這些使用者故事進行排序。另一方面,整個開發過程被分為多個迭代(Sprint),專案負責人會根據開發團隊的預估點數(Estimated Points),結合上一個迭代團隊的完成能力(Velocity),從產品功能特性列表中挑選一些使用者故事來實現。在開發過程中,專案負責人以及客戶都會參與進來,不僅如此,在每次迭代結束的時候,團隊都會向專案負責人進行功能演示,倘若功能實現上有所出入,那麼這些功能上的修改將被記錄在案,以便在後續的迭代中改進,這樣就能夠保證所開發的軟體系統不會偏離實際需求太遠,為專案或產品的最終成功交付奠定了基礎。

當然本文的主要宗旨並不是對敏捷開發過程進行論述,而是更多地考慮,當出現需求變更、功能改進,以及增添新功能的情況時,我們應該怎麼辦。遇到這樣的問題,我們大致應該從兩方面考慮:1、以哪種方式修改設計和程式碼最合適?最好是既簡單快捷,又能儘量避免造成已有設計和程式碼的大範圍修改;2、一旦發生設計和程式碼的修改,如何保證(或者說得知)這些修改不會影響到已經實現並經過嚴格測試的系統功能?不幸的是,要能夠做好其中的任何一方面,都不是件容易的事情。前者要求整個軟體系統有著良好的設計,而後者則要求這種設計是可測試的。

首先,團隊應該更合理地將物件導向的分析和設計技術引入到軟體系統的開發中來,對系統分析和設計引起足夠的重視。或許有人會說當今流行的軟體開發方法論有很多,比如面向函數語言程式設計,也時常看到有一部分人會對程式導向的“麵條式”程式設計情有獨鍾。當然,對於像我這種每天都浸泡在物件導向世界裡的程式設計師而言,使用一些程式導向的方式編寫一些小程式也別有一番趣味,但不得不承認的是,當今大型企業級複雜軟體系統開發中,物件導向分析和設計技術仍然佔據著權威性的主導地位,縱觀流行的開發技術和平臺:.NET、JAVA、C++都是以物件導向為基礎的,Python、PHP、Ruby、Lua等等,對物件導向技術也有著很好的支援。事實上,物件導向技術已經為我們的第一個問題提供了答案,我們需要做的是,在專案中合理地利用這種技術,而這恰恰也就是最大的難點,它要求團隊有著較高的技術素養和豐富的實戰經驗。

我曾經做過這樣一個專案,在這個專案的一個迭代中,團隊需要實現這樣的功能:在一些特定的條件下,比如當使用者線上註冊3天后,或者每個季度結束的時候,能夠在使用者的個人資訊主頁上看到報表的顯示連結,當使用者點選這些連結時,能夠開啟並檢視相應的報表。這樣的需求實現起來並不困難,最直接的方式就是在開啟頁面的時候從後臺對這些條件進行判斷,以決定是否顯示相應的連結。然而,在經過細緻的分析之後,我們發現,使用基於物件導向的事件模型來解決這樣的問題會顯得更加自然,並且易於擴充套件:幾乎所有的判定條件都是以“當……時,將會(將能夠)……”的句式進行描述,這就是事件模型的經典應用場景。之後所發生的事情讓我們慶幸當時的選擇是正確的:在下一個迭代中,客戶要求不僅要能夠在使用者的個人資訊主頁上看到報表的顯示連結,而且還要以電子郵件的形式通知客戶:我們已經為您準備了一份報表,請登入您的個人主頁進行檢視。接下來,我們向已有的系統新增了一個新的模組,用來偵聽來自事件模型的訊息,並且在訊息處理器(Event Handler)中,根據訊息資料和電子郵件模板來產生一封郵件,並將其傳送出去。我們得到的結果是:完全沒有改變已有程式碼的任何部分,因此已有程式碼不需要進行迴歸測試,我們僅僅是新增了一個模組,修改了程式的配置檔案,並對這個模組做了單元測試和整合測試,整個過程僅僅花了團隊不到一週的時間,而每個迭代卻是覆蓋了三週的時間。這不僅提高了團隊的生產率,而且保證了專案和產品的質量。在這個案例中,我們沒有選擇那種直觀並且易於實現的方式,而是對功能需求進行了細緻的設計,並選擇了一種相對較為複雜的方式,然而後續的故事驗證了這種取捨的正確性。倘若我們選擇了直觀簡易的方式,那麼當客戶需求更改或者功能需要新增的時候,我們需要對已有程式碼中所有產生報表連結的部分進行修改,新增電子郵件的傳送功能,我們需要改變已有的測試用例,以滿足新的需求,我們還需要對這些修改過的程式碼進行迴歸測試,以確保之前的報表連結能夠正確產生,三週時間或許勉強能夠完成這些工作。別忘了一件更讓人頭疼的事情:在下一個迭代中,客戶要求我們不僅需要傳送電子郵件,還需要根據使用者自己的隱私設定,選擇性地向他們傳送短訊息提醒。好吧!程式碼再改一次,測試用例再改一次,再做一次迴歸測試。其實,物件導向分析和設計的基本原則早就提醒過我們,這種做法會引來無窮的隱患:我們的做法從根本上違反了“開-閉原則(Open-Closed Principle)”!

以上是一個真實的案例,我相信重視併合理利用物件導向分析和設計技術的好處,不用我再用過多的筆墨去論證,我想闡述的是,開發前的分析和設計的確需要花費一定的時間,但團隊不要在這方面過於吝嗇,分析和設計做好了,便能夠在後續開發過程中受益(比如節省時間、提高質量),而且多數情況下,團隊的受益往往要多於之前在分析和設計上的付出。對於這一點,有些讀者或許會有不同的觀點,這也很正常,畢竟專案的實際情況會有所不一。比如嵌入式硬體驅動的開發,或者是化合物分子量計算演算法的實現等等,在這些場景中,或許採用結構化程式設計的方式效率更高更快捷,於是也就不存在上面討論的這些問題了。

單元測試的敏捷實踐

既然我們對專案程式碼進行了改變,新增了新的模組也好,通過重構改善既有設計也好,我們總是需要保證這些改變不會影響到已有的功能實現,這也就是上面所提到的第二個問題。從開發人員的角度看,解決這個問題最好的辦法就是每完成一次程式碼更改,都將所有的單元測試全部執行一次,確保所有的單元測試都能順利通過,如果單元測試的程式碼覆蓋率比較高的話,那麼單元測試的全部通過就表示測試所覆蓋的程式碼行為跟先前的行為是一致的,也就是說,新的更改並沒有影響到已有的程式碼功能。從整個專案的角度出發,這其實是一種持續整合的軟體開發實踐:開發人員會經常整合這些程式碼變更,通常每個成員每天至少整合一次,也就是專案上每天會發生多次整合,每次整合都通過自動化過程(編譯、部署、自動化測試)來驗證,從而在儘可能早的階段發現錯誤,減小因設計和程式碼的變更帶來的質量風險。

由此可見,單元測試對敏捷專案是多麼的重要。所以,單元測試不僅要寫,而且也要進行合理的設計,以提高單元測試的程式碼覆蓋率。通常來講,單元測試的設計和編寫應該遵循以下幾個原則:

  • 單元測試應該僅測試程式碼中的一個特定功能。顧名思義,僅測試程式碼中的一個單元。有些文獻中會認為單元測試的目的不是為了找到缺陷(Bug),而是為了邏輯的驗證。這個觀點我們之後再單獨討論
  • 基於上述原則,單元測試不能引入跟測試點無關的程式碼。假設我們需要對“變更使用者的收貨地址”這段程式碼進行單元測試,很多開發人員習慣在單元測試中訪問資料庫、讀取訊息佇列等等,雖然從功能上講,這些內容與程式碼的功能實現息息相關,但這些都不是“變更使用者的收貨地址”的核心功能。資料庫的訪問,應該由資料庫的開發團隊去測試,訊息佇列的讀取,應該由訊息佇列的開發團隊去測試。我們需要關心的是:假設資料庫的訪問是正確的,我們需要得知,當這段程式碼被執行後,使用者的收貨地址的值是否真的發生了變更;假設訊息佇列的訪問是正確的,我們還需要得知,當這段程式碼被執行後,使用者收貨地址變更的訊息是否真的能夠被髮送到訊息佇列中去。簡而言之,單元測試需要關注的是一些“訊號”:它們的傳送和接收是否正確
  • 單元測試的執行應當足夠快。持續整合要求每一次程式碼簽入都要將所有的單元測試執行一次。如果單元測試執行速度太慢,不僅影響開發效率,而且也會阻礙持續整合的應用。事實上,如果我們遵循以上兩條原則,單元測試的執行速度應該是可以滿足需要的

於是,我們編寫的程式碼就應該能夠讓針對這些程式碼的單元測試滿足以上的原則,這也就是我們平時提的最多的“程式碼可測試性”。如何讓程式碼可測試?採用基於抽象的物件導向設計技術,可以幫助我們滿足這樣的需求。

說明:在Stackoverflow上有過這樣的討論:由單例模式(Singleton)實現的程式碼可測試嗎?在眾多答案中,更為合理的解釋是:雖然通過Fake技術可以實現程式碼的可測試性,但單例模式不是最好的設計。單例無非是更改了物件的生命週期,能夠達到相同效果的一個更合理的設計是基於抽象(介面)進行設計,然後使用依賴注入框架來管理物件的生命週期。這種做法不僅靈活度高,而且設計本身是可測試的。

舉一個很簡單的例子:在ASP.NET MVC/Web API的控制器(Controller)中,我們會使用倉儲來讀取聚合根,然後執行相關的業務操作。比如很多情況下,我們會這麼做:

public class MyController : ApiController
{
    private readonly CustomerRepository customerRepository 
        = new CustomerRepository();
    
    public IHttpActionResult GetCustomerById(Guid id)
    {
        var customer = customerRepository.GetByKey(id);
        // ...
        return Ok();
    }
}

這種設計大致可以用下面的UML類圖表示:

image

上面的程式碼產生了MyController與CustomerRepository之間的關聯(Association)關係。這種關係導致MyController的實現依賴於CustomerRepository。假設我們需要對GetCustomerById進行單元測試,我們勢必需要構造一個MyController的例項,而此時CustomerRepository也被構造,於是對MyController的單元測試需要依賴於CustomerRepository的實現。從實現上看,我們首先需要配置一個Customer倉儲,使其能夠正常工作,然後將測試資料匯入到倉儲中,進而再對GetCustomerById進行所謂的單元測試。在測試執行時,測試用例會主動訪問資料庫或者其它的資料儲存機制,來獲得特定的數值,然後判定我們需要的結果是否正確。

相信很多專案會這麼去做單元測試,當然不排除有些專案本身存在歷史遺留問題的可能性,其實這種做法更多地包含了整合測試的元素:它整合了外部資源的訪問。就單元測試而言,首先測試的執行是緩慢的,外部資源的訪問大大降低了測試效率;其次測試是不穩定的,如果外部資源訪問失敗,或者測試資料發生了更改,我們的測試用例就會失敗,而這卻不是我們所需要的;最後,如果CustomerRepository的實現發生了變化,我們不僅需要對整個控制器進行重新編譯,而且所有的單元測試都需要重新執行一次,緊耦合給我們帶來了無限困擾。對上述程式碼的重構已經刻不容緩。

我們可以引入一個ICustomerRepository的介面,並使得MyController僅關聯ICustomerRepository介面,而CustomerRepository則實現了這個介面。如果你還是在MyController中直接使用new關鍵字來新建CustomerRepository的例項,比如:

private readonly ICustomerRepository customerRepository 
    = new CustomerRepository();

那麼這種做法與上面的做法還是沒有區別:MyController仍然依賴於ICustomerRepository介面的一種實現。正確的做法應該是通過MyController的建構函式,將ICustomerRepository的實現型別傳入,這樣就完全解耦了MyController和CustomerRepository。參考程式碼如下:

public class MyController : ApiController
{
    private readonly ICustomerRepository customerRepository;
    
	public MyController(ICustomerRepository customerRepository)
	{
		this.customerRepository = customerRepository;
	}
	
    public IHttpActionResult GetCustomerById(Guid id)
    {
        var customer = customerRepository.GetByKey(id);
        // ...
        return Ok();
    }
}

public class CustomerRepositoryImpl : ICustomerRepository { }

以下是UML類圖:

image

如果我們對這樣的設計進行單元測試,我們可以使用Mock技術,建立ICustomerRepository的樁(Stub)物件,同時假設在呼叫這個樁物件的GetByKey方法時,返回某個特定的Customer例項,從而驗證MyController中GetCustomerById方法的邏輯正確性。難怪社群中會有人認為,單元測試其實是一個驗證的過程。基於這種設計,對於GetCustomerById方法的單元測試可以這樣寫(使用Moq Framework):

[TestMethod]
public void GetCustomerByIdTest()
{
	var customer = new Customer();
	Mock<ICustomerRepository> mockCustomerRepository
		= new Mock<ICustomerRepository>();
	mockCustomerRepository
		.Setup(x => x.GetByKey(It.IsAny<Guid>()))
		.Returns(customer);
	var myController = new MyController(mockCustomerRepository);
	var returnedCustomer = myController.GetCustomerById(Guid.NewGuid());
	Assert.AreEqual(customer, returnedCustomer);
}

回顧上面的單元測試設計原則,顯而易見這樣的單元測試是滿足要求的。通過這個簡單的案例我們也可以看到,合理的系統設計對於單元測試的編寫是何等的重要。雖然Microsoft Visual Studio 2012/2013 Fake Framework(在Visual Studio 2010中需要額外安裝Microsoft Pex and Moles擴充套件)還有TypeMock等收費的Mock框架通過一定的技術能夠做到對於不可測試的程式碼進行單元測試,但這不是完美的解決方案,這些Mock框架還是有一定的侷限性。從系統開發的角度出發,我們更希望能夠讓我們設計和開發的軟體是穩定的、高效的、可測的、靈活的,以及可維護的。總而言之,我們的設計應該是可測的,這一點對於敏捷開發實踐尤其重要,或者直接從單元測試入手,以測試驅動開發(Test-Driven Development)的方式,一步步地實現一個可測的設計。

在此我們不再細究單元測試中Stub、Mock以及Fake的概念,我們需要反覆強調的是合理設計的重要性。對於物件導向分析與設計(OOAD)而言,遵循SOLID設計原則是非常重要的,系統設計的好壞將直接影響到專案管理(需求管理、資源分配、成本管理、進度管理等方面),甚至整個專案的成敗。

依賴倒置原則(Dependency Inversion Principle)

依賴倒置原則是OOAD “SOLID”設計原則中“D”所表示的意思。這是一個很有趣的事情,讓我們以通俗的方式來理解這個問題。仍然以我們上面改進後的設計為例,倘若現在我們要在Visual Studio中開發這麼一個Web API,你會將這些類和介面寫在哪個或者哪些程式集(Assembly)中?你會將所有的類和介面都定義在Web API這個專案中嗎?

如果你的答案是:No,那麼進一步考慮這個問題:你會將ICustomerRepository介面和它的實現類:CustomerRepository類定義在同一個專案(也就是Class Library專案)中嗎?如下:

image

此時你的答案或許會是:Yes。我們再進一步思考,如果是這樣的話,WebAPI專案就要依賴於Repositories專案,表面上看沒什麼不妥,而實際上每當CustomerRepository的實現發生更改,我們都要重新編譯和釋出整個WebAPI,而CustomerRepository的變更卻又是WebAPI所不關心的,因為它根本無需關心ICustomerRepository介面是如何實現的。另一方面,如果我們新增加了一種ICustomerRepository的實現,那麼這個新的實現也要引用MyProject.Repositories專案,於是,CustomerRepository的變更又會影響到這個新實現所在的程式集。

經過分析我們不難發現,合理的做法應該是將ICustomerRepository介面定義在WebAPI專案中,也就是:

image

原因很簡單:WebAPI專案中的MyController僅依賴於ICustomerRepository介面,而不是CustomerRepository這個具體的實現型別。這也不難理解,接下來的事情就比較有趣了:在我們編寫CustomerRepository程式碼的時候,我們要在Visual Studio中,在MyProject.Repositories專案上新增對MyProject.WebAPI專案的引用,否則無法獲得ICustomerRepository這個介面的定義!簡單地說,本來應該是A需要依賴B中的東西,來實現A自己的功能,現在反過來B需要引用A來實現A中抽象的部分。這就是依賴倒置的基本概念。

難道這麼做不會產生迴圈引用嗎?如果你還是試圖在MyProject.WebAPI中引用MyProject.Repositories,以獲取CustomerRepository實現類,那麼你的設計仍舊是糟糕的。MyProject.WebAPI為什麼要去關心ICustomerRepository介面的具體實現是什麼樣子的呢?完全沒必要關心。那怎麼辦?使用依賴注入框架!(也就是“控制翻轉/依賴注入(IoC/DI)”的由來)

其實,更為合理的設計應該是這樣的:

image

MyProject.WebAPI和MyProject.Repositories都引用MyProject.RepositoryContracts專案,而在這個專案中,包含了所有Repository的介面定義。有興趣的朋友可以思考一下,這種設計的優點在哪裡。

小結

本文詳細討論了優秀的設計(尤其是物件導向分析與設計)對單元測試的重要性、單元測試對持續整合的重要性,以及持續整合對敏捷開發的重要性。要實踐敏捷開發,一個優雅、合理的設計必不可少。文章最後還簡單討論了依賴倒置原則,這也是Apworks框架設計所遵循的基本原則。下一部分將介紹Apworks框架設計對OOAD設計原則的支援。

相關文章