博碩文化

撰寫單元測試(unit test)是在進行驗證,更是在進行設計。更有甚者,它其實也是在撰寫說明文件(documentation)。撰寫單元測試終結了許多回饋循環,尤其是在功能驗證方面。

測試驅動開發

假設我們遵循如下三條簡單的規則:

1.     在撰寫一個單元測試(測試失敗的單元測試)前,不撰寫任何產品程式碼。

2.     只撰寫剛好無法通過的單元測試,不能編譯也算無法通過。

3.     只撰寫剛好能通過測試失敗(當前測試失敗 )的產品程式碼。

如果遵循著這三條規則來工作,我們就是在非常短的循環週期進行工作。我們僅僅撰寫剛好不能通過的單元測試,接著撰寫正好能通過該單元測試的產品程式碼。我們每一兩分鐘就在這些步驟之間交替。

第一個也是最明顯的效果是,程式中的每一項功能都有測試來驗證它的操作的正確性。這個測試套件可以為之後的開發提供支援。無論何時我們因疏忽而破壞了某些舊有的功能,它就會告訴我們。我們可以向程式中增加功能,或者更改程式結構,而不用擔心在這個過程中會破壞重要的東西。測試告訴我們程式仍然具有正確的行為。如此,我們就可以更自由地對程式進行改進。

還有一個更重要但不那麼明顯的效果是,先寫測試可以迫使我們從不同的角度看待問題。我們必須從程式呼叫者的有利視角去觀察我們將撰寫的程式。這樣,我們就會在關注程式功能的同時,也直接關注它的介面。透過先寫測試,我們就能設計出便於呼叫(conveniently callable)的軟體。

此外,透過先寫測試,我們迫使自己把『程式』設計成可測試的(testable)。把程式設計成易於呼叫和可測試的,非常重要。為了易於呼叫和可測試,程式必須和它的周遭解耦。如此,先寫測試會迫使我們去除軟體間的耦合。

先寫測試的另一個重要效果是,測試可以作為一種非常有價值的說明文件。如果想知道如何呼叫一個函式或建立一個物件,會有一個測試示範給你看。測試就像一套範例,它幫助其他程式設計師瞭解如何使用那些程式碼。這份說明文件是可編譯、可運行的。它永遠保持在最新的狀態。它不會說謊。

先寫測試的例子

最近,我撰寫了一個名為獵獸(Hunt the Wumpus)的程式,僅僅是為了好玩。這是一個簡單的冒險遊戲,玩家在洞穴中移動,設法在被怪獸吃掉之前殺掉怪獸。洞穴是由一系列透過通道相互連接的房間所組成的。每一個房間可具有通往東、南、西、北方向的通道。玩家透過告訴電腦要行走的方向來四處移動。

TestMove(Listing 4-1)是我為這個程式首批撰寫的一個測試。這個函式建立了一個新的WumpusGame物件,透過東面的通道把房間4連接到房間5,把玩家放置在房間4中,發出了向東移動的命令,接著斷言(assert)玩家應該在房間5當中。

Listing 4-1

[Test]
public void TestMove()
{
  WumpusGame g = new WumpusGame();
  g.Connect(4,5,"E");
  g.GetPlayerRoom(4);
  g.East();
  Assert.AreEqual(5, g.GetPlayerRoom());
}

這段測試程式碼是在撰寫WumpusGame的任何程式碼之前完成的。我採用了Ward Cunningham的建議,按照自己希望看到的方式撰寫了這個測試。我相信只要按照測試所暗示的結構去撰寫WumpusGame,就能夠通過測試。這種方法稱為基於意圖的程式設計(intentional programming)。在實作之前,先在測試中陳述意圖,並使你的意圖盡可能地簡單、易讀。你相信這種簡單和清楚,會給程式指出一個好的結構。

基於意圖的程式設計立即引導我做了一個有趣的設計決策。測試程式碼中沒有使用到Room類別。把一個房間連接到另一個房間的動作表達了我的意圖。看起來,我不需要使用Room類別(因為這不會使表達變得更容易)。相反地,我可以僅僅使用整數來表示房間。

這看起來好像不夠直觀。畢竟,這個遊戲在你看來似乎都是關於房間的:在房間之間移動;發現房間中包含的東西等等。由於缺乏了一個Room類別,我的意圖所暗示的設計是否有缺陷呢?

我可以說在Wumpus遊戲中,連接(connection)這個概念要比房間重要得多。我可以說這個初始測試指出了一個解決問題的好方法。的確,我認為事情是這樣的,但那並不是我想要闡述的關鍵所在。關鍵之處在於:測試在非常早的階段就為我們闡明了一個重要的設計議題。先寫測試的行為就是在各種設計決策中,進行辨別的行為。

注意,測試告訴我們程式是如何工作的。我們中的大多數都可以非常容易地根據這個簡單的規格說明(specification;spec.)來撰寫WumpusGame當中已經命名的4個方法內容。同樣,命名並撰寫其他3個方向的命令也沒有什麼困難的。如果以後我們想知道如何把兩間房間連接起來,或者如何朝一個特定的方向移動,這個測試會直截了當地示範『該如何去做』給我們看。這個測試充當了用來描述程式的說明文件(document),並且是可編譯、可運行的。

測試促使模組之間隔離

在撰寫產品程式碼之前先寫測試,常常會暴露程式中應該被解耦合的地方。例如,圖4-1是一個薪水支付應用的簡單UML圖。Payroll類別使用EmployeeDatabase類別取得Employee物件,要求Employee計算自己的薪水,接著把計算結果傳遞給CheckWriter物件產生出一張支票,最後在Empolyee物件中記錄下支付資訊,並把Empolyee物件寫回到資料庫當中。

假設我們還未撰寫任何程式碼。這張圖也是在經歷了一次快速的設計探討之後,剛剛才畫在白板上的 。現在,需要撰寫規定Payroll物件行為的測試。撰寫這個測試涉及到許多問題。首先,要使用什麼資料庫呢?Payroll物件需要從若干種類的資料庫中讀取資料。必須要在對Payroll類別進行測試前,撰寫一個功能完善的資料庫嗎?要把什麼資料載入到資料庫當中呢?其次,如何來驗證列印出來的支票的正確性呢?我們無法撰寫出一個能夠觀察印表機列印出來的支票並驗證其上金額正確性的自動化測試程式!

使用MOCK OBJECT模式(仿物件模式) 可以解決這些問題。我們可以在Payroll類別以及它的所有協作者之間插入介面,建立實作這些介面的測試替身(test stub;測試樁)。

圖4-2展示了這個結構。現在,Payroll類別使用介面和EmployeeDatabase、CheckWriter及Employee進行通訊。建立了3個實作這些介面的MOCK OBJECT。PayrollTest物件對這些MOCK OBJECT進行查詢,以檢驗Payroll物件對它們進行的管理是否正確。

Listing 4-2展示了測試的意圖。測試中建立了合適的MOCK OBJECT,把它們傳遞給Payroll物件,告訴Payroll物件為所有員工支付薪水,接著要求MOCK OBJECT去驗證所有已開支票的正確性以及所有已記錄支付資訊的正確性。

Listing 4-2  TestPayroll

[Test]
public void TestPayroll()
{
  MockEmployeeDatabase db = new MockEmployeeDatabase();
  MockCheckWriter w = new MockCheckWriter();
  Payroll p = new Payroll(db, w);
  p.PayEmployees();
  Assert.IsTrue(w.ChecksWereWrittenCorrectly());
  Assert.IsTrue(db.PaymentsWerePostedCorrectly());
}

當然,這個測試只是簡單檢查了Payroll是否使用所有正確的資料呼叫了所有正確的函式。測試並沒有檢查支票是否列印,也沒有檢查是否正確更新了一個真正的資料庫。相反地,它檢查的是Payroll類別是否具有『它在獨立情況下』相同的行為。

你也許想知道為何需要MockEmployee類別。看起來好像可以直接使用真實的Employee類別。如果真是那樣,那我會毫無顧忌地使用它。在本例中,我認為對於檢查Payroll類別的功能來說,Employee類別顯得複雜了些。

意外獲得的解耦

對Payroll類別的解耦是件好事。它使得我們可以使用不同種類的資料庫和支票印表機,這種互換能力既是為了測試,也是為了應用的擴展性。我覺得為了進行測試而進行解耦是有趣的。顯然,為了測試而對模組進行隔離,會迫使我們用『對於整個程式結構有益』的方式來對程式進行解耦。在撰寫程式碼之前先寫測試改善了設計。

驗收測試

作為驗證工具來說,單元測試是必要的,但不夠充分。單元測試驗證了系統當中那些小的組成單元應按照期望的方式工作,但是它們並未驗證『系統作為一個整體時的工作正確性』。單元測試是用來驗證系統中單個機制的白盒測試(white-box test) 。驗收測試則是用來驗證系統滿足客戶需求的黑盒測試(black-box test) 。

驗收測試是由不瞭解系統內部機制的人撰寫的。這些測試可以由客戶直接撰寫,或者由業務分析師、測試人員及品質保證專家來撰寫。驗收測試是自動執行的,通常使用一種特定的規格描述語言來撰寫,這種語言比較適合非技術人員閱讀與使用。

驗收測試是關於一項特性(feature)的最終說明文件。一旦客戶寫好了驗證某項特性的驗收測試,程式設計師就能夠透過閱讀那些驗收測試來確實理解該項特性。所以,正如單元測試作為可編譯、可執行的、有關系統內部結構的說明文件那樣,驗收測試是有關系統特性的可編譯、可執行的說明文件。簡而言之,驗收測試是真正的需求文件(the acceptance tests become the true requirements document)。

此外,先撰寫驗收測試對於系統的架構來說,具有深遠的影響。為了使系統具備可測試性,就必須要在高的系統架構層面對系統進行解耦。例如,為了使驗收測試無需透過UI就能夠存取到業務規則,就必須解除UI和業務規則之間的耦合。

在專案初期的iterations期間,會受到用『手工』方式進行驗收測試的誘惑。但是這樣做非明智之舉,因為如此一來,源於『自動化』驗收測試而對於系統進行解耦的動力就喪失了。如果在最早的iteration一開始時,你就清楚地知道必須要採用自動化驗收測試,那麼你在系統架構方面,就會做出非常不同的權衡。並且,正如單元測試會促使你在小地方做出優良的設計決策那樣,驗收測試會促使你在大方向做出優良的系統架構決策。

再次回頭來看看薪水支付應用程式。在首次的iteration中,必須能夠在資料庫中增加和刪除員工。也必須能夠為目前存在於資料庫中的員工開立支付薪水的支票。所幸,我們只需處理領月薪的員工。其他種類的員工可以放在後面的iteration中再來處理。

我們尚未撰寫任何程式碼,也還沒有進行任何的設計。這是開始考慮驗收測試的最好時機。基於意圖,程式設計再一次成為有用的工具。我們應該按照我們對於驗收測試的想法來撰寫它們,然後就能夠據此來設計薪水支付系統。

我想使驗收測試便於撰寫且易於改變。我想把它們放置在一個協作工具當中,並且可以透過內部網路存取,這樣就可以隨時運行。因此,我使用開放原始碼的FitNesse工具 。在FitNesse中,可以用簡單的Web形式來撰寫每個驗收測試,並透過瀏覽器來存取與運行。

圖4-3展示了一個在FitNesse中撰寫的驗收測試範例。在測試的第一步中,向薪水支付系統增加兩名員工。在第二步中,向他們支付薪水。第三步是確保支票的書寫正確。在這個例子中,我們假設正好扣除20%的稅。

顯然,這種測試對於客戶來說,也非常容易閱讀和撰寫。但是請考慮一些它隱含的系統結構。測試的頭兩個表格是薪水支付應用程式的功能。如果你想把薪水支付系統寫成一個可重用的框架(reusable framework),那麼它們所對應的就是API函式。事實上,為了讓FitNesse能夠呼叫這些函式,就必須撰寫出這些API 。

意外獲得的架構

請注意驗收測試對於薪水支付系統架構的影響。正是因為先考慮了測試,才引領我們去思考薪水支付系統API函式的概念。顯然,UI將使用該API來完成其功能。同樣請注意,支付支票的列印也必須與Create Paychecks函式解耦。這些都是好的架構決策。

總結

測試套件運行起來越簡單,就會被越頻繁地運行。測試運行得越多,就會越快發現任何與測試背離的事。如果能夠一天多次地運行所有的測試,那麼系統失效的時間就決不會超過幾分鐘。這是一個合理的目標。我們決不允許系統倒退。一旦它工作在一個確定的級別上,就決不能讓它倒退回一個稍低的級別。

然而,驗證僅僅是撰寫測試的好處之一。單元測試和驗收測試都是一種說明文件的形式。這樣的說明文件是可被編譯和執行的;因此,它是準確和可靠的。此外,撰寫測試所使用的語言是明確的,並且非常容易讓觀看者閱讀。程式設計師能夠閱讀單元測試,因為單元測試是使用程式語言撰寫的。客戶能夠閱讀驗收測試,因為驗收測試是使用簡單的表格式語言撰寫的。

或許,測試最重要的好處在於,它對架構和設計的影響。為了使一個模組或應用程式具備可測試性,必須要對它進行解耦。越具有可測試性,耦合關係就越弱。全面地考慮驗收測試和單元測試,對於軟體的結構具有深遠的正面影響。(摘錄整理自第四章)

 

 書籍檔案 

無瑕的程式碼:敏捷完整篇──物件導向原則、設計模式與C#實踐

Robert C. Martin, Micah Martin/著、鄧輝、孫鳴/譯、陳錦輝/審校

博碩文化出版

售價:790元

 作者簡介 

Robert C. Martin

人稱Uncle Bob,程式設計經驗超過40年,Agile Software(敏捷軟體開發)的提倡者之一。創立Object Mentor,這是一間專注於C ++、Java物件導向、模式、UML、敏捷方法學和極限程式設計的顧問諮詢公司。


Advertisement

更多 iThome相關內容