Delphi 自動化單元測試

摘要:介紹單元測試的基本觀念,以及 DUnit 的使用方法。

作者:Malcolm Groves
翻譯:蔡煥麟
校稿:朱子
日期:Aug-1-2002

譯者小引

這是我翻譯的第二篇關於 DUnit 的文章,第一篇是【DUnit - Delphi 的終極測試工具 】,該譯文已經被納入 DUnit 官方套件裡,如果您要試試看 DUnit,可以到 http://dunit.sourceforge.net/ 下載完整套件。翻譯此文的目的,除了讓自己多了解 DUnit 單元測試,同時也希望提供 Delphi 同好們更多關於單元測試的中文參考資料,也許在短期內,大家還無法立刻接受自動化單元測試帶來的工作習慣的改變(先寫測試),但即使是在觀念上有所認知也是好的,先了解單元測試的好處是什麼,要解決的問題是什麼,等到需要時自然會想到它,然後進一步去學習、運用它。

這篇文章的翻譯是經過作者同意才進行的,如欲觀看原文,請至 http://www.madrigal.com.au。原文尚未正式底定,範例程式碼也未釋出,所以原始文件也許還會再修改,屆時譯文將隨原文一併更新。譯文若有不當之處,也歡迎大家批評指正。


問題

這問題你遇過多少次了?你要修改的程式碼不是你自己寫的,或者是你好久以前寫的,因此,你得花點兒時間(重新)熟悉它們,然後修改它們,並且執行個幾次來驗收這些修改。大約一個星期之後,當品保(QA)完成了新建構(build)的程式,你收到了一串跟手臂一樣長的臭虫清單,誰知道那些修改竟造成這麼大的衝擊?你又得花好多時間了解程式碼,以及更多的時間去追蹤這些問題。

所幸這是個常見的問題,也是個老問題了,如果我們能夠解決其他更複雜的問題,這個當然也難不倒我們。

解法1:更聰明的編譯器

有個理想的解決方案,就是一個不只能夠檢查程式的語法,還能檢查程式邏輯是否正確的編譯器。我知道這樣的要求不算多,但就我對 Scotts Valley(譯註1) 科學家的印象來看,這樣的編譯器功能肯定不會出現在下一版的 Delphi。

但是,請先假裝一下你已經有這樣的編譯器了,當你寫出來的程式碼不是你想要的,它會告訴你寫錯了;當你的商業邏輯不正確,或者因為修改了某些地方而破壞其他相依的程式碼時,它也會舉旗通知你。好,大家都有在假裝吧?

這對寫程式的方式有什麼影響?對初學者來說,只要程式的邏輯不正確,編譯器就會提出警示,你不再需要努力回想上個月寫這個特別的方法時腦子裡在想什麼。如果你對這些盤根錯節的邏輯都還記憶猶新,你就能很快地了解它,而且需要查看的程式碼範圍也會縮小。如果你的程式在五分鐘之前通過了邏輯編譯,現在卻發生錯誤了,你就知道問題一定是出在剛才的五分鐘裡面你所做的事情。

此外,它也讓你更勇於修改你的程式,你將不再聽到這句話:「既然程式沒問題,幹嘛要修改它?」。突然間,我們都成了印第安那瓊斯(譯註2),在程式叢林裡艱難地前進著,手持彎刀,披荊斬棘地進行重構(refactoring);我們可以放心地砍掉整團程式碼,以更簡潔優雅的方式重寫,因為如果因此而破壞了其他相關的程式碼,只要編譯一下就能立刻發現了。

我想我最好冷靜一下,我能聽到你們在嘀咕:「印第安那瓊斯?這傢伙到底在鬼扯什麼東西啊?」不過,我真的很想要這種編譯器。雖然我沒有,但我想接下來介紹的東西比它更棒。

解法2:自動化單元測試

前面所描述的編譯器實際上並不存在,一旦我們接受了這個事實,我們就得看看有沒有其他方法能獲得同樣的好處。有個方法在 Java 和 Smalltalk 的世界裡普遍受到歡迎,它就是自動化單元測試(Automated Unit Testing)。

在進一步了解單元測試之前,值得花點時間了解它不是什麼。首先,單元測試不是黑箱測試(Black Box Testing)。在理想的情況下,用來測試的程式碼(以下簡稱測試碼)以及被測試的類別應由同一位開發人員撰寫。測試碼通常會包含一些用來驗證類別內部實作的測試,此做法並非必要,但有些人覺得很有用。

譯註:

因為測試碼除了用來測試類別的功能和介面之外,通常還會測試類別的內部結構(例如:私有成員的數值是否超過邊界),所以撰寫測試的人必須對類別的內部實作很清楚。這也就是為什麼測試碼及被測類別要由同一人撰寫的緣故,亦同時說明了單元測試不是黑箱測試。

單元測試也不是功能測試(Functional Testing)。雖然我們還是可能用它來做部分的功能測試,但驗證軟體功能是否符合規格並非單元測試的主要用途。

所謂的單元測試,是一種能夠為你的系統中的類別定義一組測試的方法。你測試物件的程式介面,設定屬性值,呼叫方法,並且檢驗測試的結果是否和你原先所預期的結果一樣。就像你以前可能會做的,寫個簡單的 Delphi 程式,把測試資料餵給要測試的類別,然後把結果顯示在畫面上。其實測試案例(Test Case)做的也是相同的工作,只不過它是個物件,而且我們是寫程式來自動確認結果是否符合預期,而不是把結果顯示在螢幕上做人工確認。

數年前,Kent Beck 和 Erich Gamma 共同發表了 Java 的自動化單元測試框架,稱為 JUnit。受到 JUnit 的影響,其他程式語言的單元測試框架也開始發展,也有一些是給 Delphi 用的,其中最受歡迎的可能就是 DUnit 了,它是個開放原始碼的專案,而且跟 JUnit 非常相似,簡直就是它的翻版。

我可以說 DUnit 不但不會太過複雜及難以使用,而且相當彈性,有很多種不同的用法。但是我們不能期望在一篇文章裡面把所有功能或者你能想到的東西都寫進去,我的目標是希望讓你對此框架有個基礎了解,以便日後能自行探索,以及提供一些點子,讓你知道怎樣發揮自動化單元測試的最大效益。

測試(Tests)

不意外,DUnit 建立在測試的觀念上。想想看測試是由什麼組成的,你會需要一些測試的東西(稱為 fixture)以及一個執行於 fixture 上的動作,並且檢查這個動作的結果是否符合你的預期。

舉個簡單的例子,如果我們有一個 TCustomer 類別,該類別有兩個屬性:Balance(餘額) 和 DiscountLevel(折扣等級)。折扣等級會隨著現有的餘額改變(餘額小於 $10,000 時,給予 5% 的折扣,餘額大於等於 $10,000 時,折扣的計算方式較複雜,需根據 TCustomer 的其他屬性計算得出)。

以 DUnit 測試這個類別時,我們可以從 TTestCase 衍生一個子類別,叫做 TCustomerTestCase,然後我們需要一些東西來測試(也就是 fixture),所以我們會在 TCustomerTestCase 類別裡宣告一個 TCustomer 型態的私有變數。

好,現在 fixture 已經有了,接著我們需要一個測試項目。在 TCustomerTestCase 類別的 published 區段裡加入一個 TestLowDiscount 方法,在此方法中,我們要把 Balance 值設定成小於 $10,000,並且檢查 Discount 數值是否正確(也就是 5%)。怎麼檢查呢?我們可以透過 TTestCase 定義的一個通用的 Check 方法來檢查,此方法跟 Assert 有點兒像,它接受兩個參數,第一個參數是布林型態,代表檢查的結果,第二個參數可有可無,作為檢查失敗時所顯示的錯誤訊息。如果傳入布林參數的敘述結果為 True,就表示通過檢查,False 則表示檢查失敗,並且會引發一個異常(exception),該異常的訊息就是傳入 Check 方法的第二個參數。

同樣地,我們也可以宣告更多 published 方法來測試 Discount 各方面的行為,也許是 TestHighDiscount,TestZeroBalanceDiscount,以及 TestBoundaryDiscount。

我們還沒建立我們的 fixture,很明顯地,我們可以把建立與摧毀 fixture 的工作放在建構式和解構式裡面,但它們不一定是執行此工作的最佳場所。宣告在 published 區段的各個測試方法(Test*)將被依序呼叫,而在每個方法被呼叫之前,我們要確定 fixture 狀態是一致的。然而建構式只能夠確保第一個測試方法的 fixture 狀態是一致的(譯註:因為建構式只會執行一次),因此 TTestCase 提供了兩個虛擬方法:Setup 與 TearDown,它們會分別在每個測試方法執行之前和之後被呼叫。我們可以在改寫的 Setup 方法中建立一個 TCustomer 實體並初始化成某位特定的客戶,然後在改寫的 TearDown 方法中摧毀它,這樣我們就可以確定每個測試方法都有相同的初始狀態了。

譯註:關於 fixture

Fixture 是一種固定機制,它的用途是:讓一個 test-case 裡面的多項測試能夠共享物件(變數或任何資源)的初始化及清理動作。

所以, fixture 包含三種元素:

  1. 物件(一個或多個)。
  2. 初始化物件的動作。
  3. 清除物件的動作。

欲建立 fixture,你通常會知道哪些物件(或任何變數)是要被測試方法共用的,然後你必須改寫 TTestCase 的 Setup 與 TearDown 方法,把物件的初始化和清除的動作分別寫在 Setup 與 TearDown 裡面。在某些具有自動資源回收(garbage collection)功能的程式語言(例如:Java 的 JUnit),你也可以不用在 TearDown 裡面撰寫釋放資源的程式碼,但有借有還仍不失為一種好的習慣,而且有些收尾動作像是關閉檔案、資料庫連結...等,還是會用到 TearDown。

除了管理物件資源, fixture 也有其他用途,例如:管理資料庫交易。如果你正在測試資料庫應用程式,並且希望測試的過程不會改變資料庫的內容,也可以在 TearDown 裡面將所有的交易撤回,或者撰寫交易補償的程式碼。

好啦,我們已經定義好測試,並且確保它們都有相同的初始條件了,那我們要怎麼執行它們呢?我們得先向 DUnit 框架註冊 TTestCase 的子類別,請把下面這行程式碼加到單元的初始化區段:

RegisterTestCase(TCustomerTestCase.Suite);

現在框架已經認識我們的測試案例了,只要再叫它去執行我們的測試就行了。開啟包含 TCustomerTestCase 的專案原始碼,並且把下面這行:

Application.Run;

改成:

GUITestRunner.RunRegisterTests;

所有註冊過的測試案例就會從這裡開始執行。執行時會出現一個視窗,你會看到所有的測試項目依序地執行,並顯示其結果(綠色代表成功,粉紅色代表失敗,紅色代表所有未預期的異常),畫面上的進度表也會顯示測試的總結果(綠色代表全部的測試都成功....等等)。請注意你也可以選擇以主控台(console)應用程式的方式來執行測試,自動建置系統(automated build system)有了這項功能會很方便。

我們馬上就要看看這個框架的其他部分,但我們需要知道的部分已經很多了,我們已經能夠定義測試、單獨執行或執行多項測試、並且能在任何測試失敗時立刻知道,一旦問題修正了,且全部的測試都能順利通過,我們就可以開始修改我們的類別,並在每次修改之後把測試重新執行一遍,如果有些測試突然失敗了,我們就可以確定是剛才的修改造成的。

所以,當開發人員實作此範例中的 TCustomer 類別時,只要按 F9 就可以執行測試,而且幾乎是馬上就能知道他剛才加的程式碼能否通過測試。而且,這名開發人員的測試案例可以加到整個專案的測試案例庫中,這麼一來,在每次建構(build)專案之後,或把產品送給品保之前,都可以把所有的測試執行一遍,確保所有的測試都能通過,甚至可以作為自動建置系統的一部分,讓這些工作能自動完成。

測試套件(TestSuites)

如果你停止閱讀並實際去玩一下這東西,你可能會發現呼叫 RegisterTest 時,傳入的參數並不是 TTestCase 型態,而是 ITest,一個介面型態,而 TTestCase 實作了 ITest 介面。

為什麼?是這樣的,如果我們的測試框架只認識 ITest 介面的話,我們就可以讓它不單只適用於 TTestCase 的後代。比如說?呃....像 TTestSuite 類別就是一個實作 ITest 的例子,TTestSuite 能讓你把多個實作了 ITest 的物件集合起來,並將他們視為一個單一項目來操作(例如:把所有實作 ITest 的類別視為單一群組,予以致能或除能)。其用法非常簡單,你只要先建立一個 TTestSuite 物件,然後呼叫 TTestSuite 的 AddTest 方法加入測試項目,接著你就可以在呼叫 RegisterTest 時把 TTestSuite 物件當作參數傳入,取代原本的 TTestCase 物件。

正如同你可以將 TTestCase 物件加到 TTestSuite 一樣,你也可以把其他的 TTestSuite 物件加到一個 TTestSuite 裡面,並藉此建立起測試的階層結構。

擴充功能(TestExtensions)

剛開始時(不,我沒打算要長篇大論),DUnit 就只發展到我前面介紹的這個地步,它確實非常實用,只是缺乏改進的彈性空間。還好 DUnit 有開放原始碼,所以就有一群人持續地改進它,並且把成果回饋給社群,其中有些是以擴充功能的形式出現,並且解決了不少常見的問題。

讓我們先來看第一個問題。假設你在測試你的類別時,必須先連接資料庫,而到目前為止我們所討論的內容,可能暗示你應該在測試案例的 Setup 方法中連接資料庫,並且在 TearDown 方法中關閉資料庫連線。此法雖然可行,但卻很沒有效率,因為每個測試方法都會在執行前建立資料庫連線,在結束時離線,接著又馬上要再建立一次連線。有時候你會希望資源只配置一次,讓所有的測試共用,並且讓這些資源的生命週期能跨越多個測試案例甚至測試套件,直到全部做完了才釋放它們。

這就是 TTestSetup 欲解決的問題。我們可以從 TTestSetup 衍生一個子類別,改寫其 Setup 與 TearDown 方法以配置及釋放那些我們希望活得比較久的資源,然後再建立我們的 TTestSetup 實體,建立時要把 TTestSetup/TTestSuite 物件當作參數傳入建構式。Setup 方法會先被呼叫,然後會執行傳入建構式的 TTestCase/TTestSuite 中定義的測試,最後則會呼叫我們的 TTestSetup 物件的 TearDown 方法。於是我們就可以把我們的 TTestSetup 物件註冊到框架中,而不是我們的 TTestCase/TTestSuite 物件了。

另一個問題是,你可能會想要執行同一個 TTestCase/TTestSuite 好幾次。有個老方法可以辦到,就是重覆註冊你的 TTestSuite,不過,還有更簡單的方法,那就是使用 TRepeatedTest 類別。先建立一個 TRepeatedTest 實體,建構式需要兩個參數,一個是你想要重覆測試的 TTestCase/TTestSuite 物件,另一個是重覆的次數。向框架註冊時不要用 TTestCase 物件,改成註冊 TRepeatedTest 物件,這樣它就會依照你指定的次數重覆執行測試了。

如果你有興趣知道,這 TRepeatedTest 和 TTestSetup 類別都運用了裝飾者樣式(Decorator pattern),這是一種不用修改物件原始碼就能為其附加功能的技巧,此樣式極有用處,而且就某方面來看,它也算是陽春版的 Aspect Oriented Programming。

測試程序(A Testing Process)

與其只說明自動化單元測試的 What,再多花點時間解釋 How 和 Why 也許是值得的。

我們在為客戶導入自動化單元測試時,通常會先讓他們改以某種特定的方式工作,不管有沒有人協助,他們終究會慢慢適應這個程序,而以下就是我們協助其順利導入的方法。

不管他們的專案開始進行了沒,我們會先訂下程式碼審閱(Code Reviews)的規則,所有新寫好的程式碼都必須有一個測試案例,因此在撰寫新類別以及修改既有的程式碼時,都必須為他們撰寫測試案例。客戶對此規則的第一個反應是:「哇喔!這樣會減慢開發速度耶!」是的,剛開始的確會這樣。但只要開發人員習於撰寫測試,他們通常會發現,撰寫類別和測試案例加起來所花的時間,和以前只撰寫類別的時間是一樣的。

這是怎麼辦到的?是這樣的,我們儘可能讓開發人員在撰寫類別之前先寫測試,這看似倒退的做法產生了一些有趣的結果。

首先,它強迫開發人員思考類別要做什麼,而不是怎麼做,換句話說,讓他們專注於類別的介面,而不是實作。他們會根據需求文件或規格書來撰寫測試案例,一旦測試案例完成,開發人員才實作這個類別,直到測試案例執行無誤為止。這裡的重點是,你可以輕易地知道工作何時才算完成。當你的測試案例都執行成功時,你的類別也就完成了,不再需要為類別額外加工處理(譯註3),也不再有增加一項功能要花上一天的情形。將需求實作出來,並且通過測試後,你就可以繼續處理下一件工作,這就是測試能夠幫你節省時間的原因。

另一種情形是臭虫報告。當一隻臭虫被發現時,我們要做的第一件事情就是調出對應的測試案例,加一道測試程序好讓臭虫出現,然後修改類別直到通過這項測試。這不但簡化了重現臭虫的工作,同時也讓你的測試案例愈加精準。

對於單元測試的批評,我比較常聽到的是:你不能期望所有的情況都要做測試。若你真想這麼做,那麼光是寫那些測試的時間就令人無法接受了。那麼,在那個神奇的編譯器問世以前,我們就不能期望把所有的測試狀況都考慮進來,但這並不表示我們就都不要測試了。你必須以風險導向的方式來撰寫測試案例,也就是把你認為合理的、或者會導致嚴重災難的狀況挑出來,為它們撰寫測試,至於有些你沒考慮到的情況,等日後陸續出現新的臭虫時,再加入新的測試。把邊界狀態和其他明顯的情形考慮進去,然後繼續你的工作,我們發現這種方法能戲劇性地提高生產力,且免於撰寫又臭又長的測試案例文件。

測試 GUI(圖形使用者介面)

通常在討論單元測試時,測試 GUI(圖形使用者介面)的問題就會被提出來。請記住,你可以在你的測試方法中運用任何程式技巧,例如傳送視窗訊息來模擬滑鼠移動、按鈕等事件,透過 scripting engine、OLE 自動化,或其他任何方法,只要能讓你引發 GUI 事件來驗證測試結果的都行,因此就技術上而言,DUnit 是可以用來測試 GUI 的。

不過,我認為你應該問問自己是否該這麼做。許多聰明人不同意我的看法,但我想還有其他更有效率、更可靠、以及更適當的工具可以測試 GUI,以 DUnit 目前的狀況而言,用它來測試 GUI 常令人覺得有點兒破解的味道,最後不是寫出一堆冗長、脆弱、複雜又難以維護的程式碼,就是得修改你的 fixture 物件定義,加入一些用來測試 GUI 的方法。前者通常需要為測試另外撰寫測試,不然至少也會花許多時間撰寫測試;後者則通常會在你的物件介面中加入不必要的方法和屬性。

我的建議是,在有人提出更好的方法,可以用 DUnit 寫出簡潔、易維護、且穩定的 GUI 測試程式以前,只把 DUnit 用在它能發揮最大效用的地方就好了,測試 GUI 的部分就交給專門的 GUI 測試工具,像是 AQTest 或 SleuthQASuite 等。當然啦,這只是個人的看法,你一定會聽到其他不同的意見,所以,要怎麼做就隨你高興了。

結論

在這篇文章裡,我並不期望能說服你自動化單元測試有多好,其實當我第一次聽到它的時候,我也沒想這麼多,直到閱讀了 Martin Fowler 的【Refactoring】。在看過【UML Distilled】和【Analysis Patterns】之後,我已堅信他是個絕頂聰明的傢伙,也是因為他斬釘截鐵地說,單元測試是重構以及建立可靠程式碼的基本元素,我才被說服而願意嘗試看看。經過幾天漸漸地習慣之後,我已經改變原有的觀念,接受了新的信仰。這種工作方式變得非常自然而直覺,所以,至少先試試看,再決定要不要接受它吧。

這裡有更多相關資訊:

http://www.extremeprogramming.org

http://www.junit.org

http://dunit.sourceforge.net

http://www.martinfowler.com/articles.html

本文的最新版本和範例程式可在此取得:

http://www.madrigal.com.au


譯註

  1. Scotts Valley 是美國加州的一座城市,Borland 總部設於此。
  2. 印第安那瓊斯(Indiana Jones)是冒險動作電影「法櫃奇兵」的劇中人物,由哈里遜福特飾演。
  3. 原文是 "No more gold-plating of classes...."。