DUnit Xtreme testing for Delphi

# DUnit
Delphi 的終極測試工具

by Will Watts
edited by Juanco Añez
Copyright © 1999 Will Watts. All rights reserved.
Later versions are © 2000-2003 The DUnit Group. All rights reserved.
This text may be distributed freely as long as it's reproduced in its entirety.
There's an English version of this document here

翻譯：蔡煥麟

## 內容

> 採用 DUnit 進行單元測試

> > 檔案內容
> > 起步
> > 你的第一個測試專案
> > SetUp 與 TearDown
> > 測試套件
> > 逐步建立測試套件

> 其他功能

> > 在主控台模式下執行測試
> > 擴充功能

> 參考資料

## 採用 DUnit 進行單元測試

DUnit 是一個類別框架，目的是要支援 XP 的軟體測試方法。它支援 Delphi 5 以後的版本。

其概念為，當你在開發或修改程式碼時，你就要同時開發出相稱的測試程式，而不是把它們延後到測試階段。若能隨時更新測試程式並且經常反覆地執行它們，你就能夠更輕易地產生可靠的程式碼，而且在進行修改與重整（refactorings）時更有把握不會破壞原有的程式碼，於是，應用程式等於有了自我測試的能力。

DUnit 提供了一些類別以便組織與執行這些測試。DUnit 提供兩種執行測試的方式：

- 圖形使用者介面的應用程式，讓你方便地選擇個別的以及一整組的測試。
- 主控台（console）應用程式。

DUnit 的靈感源自 JUnit 框架，該框架是由 Kent Beck 與 Erich Gamma 為 Java 程式語言所設計的，但是 DUnit 已經逐漸發展成威力更強的 Delphi 專屬工具。最早是由 Juanco Añez 設計成 Delphi 的版本，目前則是由 SourceForge 的 DUnit Group 所維護。

### 檔案內容

隨著 DUnit 套件所發布的檔案應該存放在一個屬於自己的目錄下，以便保留完整的目錄結構：

目錄名稱 | 說明
--- | ---
DUnit |
| framework | 事先編譯好的框架模組
| src | 函式庫原始碼
| doc | 輔助說明檔，網頁與 MPL 授權許可
|  | images | 網頁的圖形檔案
|  | API | Time2Help 產生的 API 文件
| Contrib | 其他人貢獻的模組
|  | XPGen | 一個可以自動產生測試案例（test cases）的工具
| tests | 給這個框架本身所使用的測試案例
| bin | 事先編譯好，可以單獨執行的 GUI 測試程式
| examples |
|  | cmdline | 示範如何在命令列環境下使用 DUnit
|  | collection | 一個類似 Java 的集合（collections）實作以及它的 DUnit 測試案例
|  | registration | 使用測試案例註冊系統（registration system）（譯註：示範幾種註冊測試案例的方法）
|  | structure | 組織測試程式碼的方式
|  |  | diffunit | 把測試案例放在獨立的單元裡面
|  |  | sameunit | 把測試案例和被測試的程式碼放在同一個單元裡面
|  | registry | 一步步教你建立一個存取 Registry 的工具及其測試案例
|  | embeddable | 示範如何將 GUITestRunner 嵌入至其他視窗內
|  | (...) |
|  | TListTest | 給 Delphi 的 Classes.TList 物件使用的測試案例

目錄 src 包含下列檔案

檔案名稱 | 說明
--- | ---
TestFramework.pas | 框架本身
TestExtensions.pas | 可用來擴充測試案例的 Decorator 類別
GUITesting.pas | 用來測試使用者介面（視窗與對話盒）的類別
TextTestRunner.pas | 在主控台模式下執行測試的函式
GUITestRunner.pas | 此框架的圖形化使用者介面
GUITestRunner.dfm | GUITestRunner Form

framework 目錄中包含以上各單元編譯過的版本，以及用來連結 .BPL 的 .DCP 檔案（對應的 .BPL 檔案存在 bin 目錄裡）（譯註1）。

### 起步

在開始使用 DUnit 之前，Delphi 的單元搜尋路徑裡必須包含 DUnit 的原始碼或編譯後的檔案路徑 。你可以在 Delphi IDE 中點選 Tools | Environment Options | Library，然後把 DUnit 路徑加到原有的路徑清單裡：

另一種做法，是將 DUnit 路徑加到預設的專案選項或者特定的專案選項裡，在 IDE 中點選 Project | Options：

### 你的第一個測試專案

建立一個新的應用程式，然後關閉 Delphi 為你自動產生的 Unit1.pas 並且不要儲存。儲存這個新的專案（在你想要測試的應用程式的相同目錄下的 'real life' 目錄）並且命名為 Project1Test.dpr。

點選 File | New | Unit 以建立一個新的（沒有 form 的）單元，由於我們會把測試案例寫在這個檔案裡面，所以儲存的時候就取 Project1TestCases 之類的檔案名稱，接著在 interface 的 uses 子句裡加入 TestFramework。

宣告一個 TTestCaseFirst 類別，該類別繼承自 TTestCase，然後實作一個如下所示的 TestFirst 方法（顯然地，這個小範例只是為了讓你順利起步），注意最後的 initialization 區段，TTestCaseFirst 類別就是在這裡完成註冊的。

    unit Project1TestCases;

    interface

    uses
      TestFrameWork;

    type
      TTestCaseFirst = class(TTestCase)
      published
        procedure TestFirst;
      end;

    implementation

    procedure TTestCaseFirst.TestFirst;
    begin
      Check(1 + 1 = 2, 'Catastrophic arithmetic failure!');
    end;

    initialization
      TestFramework.RegisterTest(TTestCaseFirst.Suite);
    end.

測試的結果是置於所呼叫的 Check 方法裡面，這裡我很無聊地想要確認 1 + 1 是否等於 2。TestFramework.RegisterTest 程序會把傳入的測試案例物件註冊到此框架的註冊系統裡。

在執行這個專案以前，點選主選單的 Project | View Source 以開啟專案的原始碼，把 TestFrameWork 以及 GUITestRunner 加到 uses 子句裡，然後移除預設的 Application 程式碼，並以下面的程式碼取代：

    program Project1Test;

    uses
     Forms,
     TestFrameWork,
     GUITestRunner,
     Project1TestCases in 'Project1TestCases.pas';

    {$R *.RES}

    begin
     Application.Initialize;
     GUITestRunner.RunRegisteredTests;
    end.

現在試著執行程式，如果一切正常，你應該會看到 DUnit 的 GUITestRunner 視窗，裡面有一個樹狀元件顯示可用的測試（目前只有 TestFirst），點一下 Run 按鈕即可執行測試。畫面上的核取方塊可以讓你以階層的方式選擇欲測試的項目，還有額外的按鈕以便切換測試項目或整個分支的選取狀態。

若要加入更多的測試，只需簡單地在 TTestCaseFirst 裡加入新的測試方法，TTestCase.Suite 類別方法會透過 RTTI（RunTime Type Information，執行時期型態資訊）自動地尋找並且呼叫它們，這些測試方法必須符合兩個條件：

- 測試方法必須是不帶參數的程序。
- 測試方法必須宣告為 published。

注意 DUnit 會為它所找到的每個方法各自建立一個類別的實體（instance），所以測試方法之間不可共享實體的資料。

現在要再加入兩個測試方法：TestSecond 與 TestThird，其宣告如下：

    TTestCaseFirst = class(TTestCase)
    published
      procedure TestFirst;
      procedure TestSecond;
      procedure TestThird;
     end;

    ...

    procedure TTestCaseFirst.TestSecond;
    begin
      Check(1 + 1 = 3, 'Deliberate failure');
    end;

    procedure TTestCaseFirst.TestThird;
    var
      i: Integer;
    begin
      i := 0;
      Check(1 div i = i, 'Deliberate exception');
    end;

如果你重新執行這個程式，你就會看到 TestSecond 測試失敗了（旁邊有一個小的紫紅色方框），而 TestThird 會丟出一個異常（旁邊的方框是紅色的），通過測試的方框會是綠色的，而沒有執行的測試則是灰色的。失敗的測試清單會被列在下方的面板上，當你去點選它們就可以在底部的面板上看到它們的詳細資料。

如果你在 IDE 裡面執行程式，你會發現每當程式發生錯誤時就會暫停，當你用 DUnit 進行測試時，這樣的行為可能不是你想要的，你可以照下面的步驟將 IDE 的這項功能關掉：點選 Tools | Debugger Options，然後把 Language Exceptions 頁夾的 Stop on Delphi Exceptions 項目取消。

### Setup 與 TearDown

我們通常會在執行一組測試之前進行一般的準備工作，並在事後進行清理。比如說，在測試一個類別的時候，你也許會想要建立該類別的實體，然後對它施行一些檢查，最後再將它釋放，如果測試項目很多的話，你將免不了在每一個測試方法裡面撰寫重複的程式碼。DUnit 對此提出的解決方案是，在每一個測試方法被執行之前和之後分別去呼叫 TTestCase 的虛擬方法 Setup 與 TearDown，以終極測試的行話來說，由這兩個方法來提供測試前的必要處理就稱為一個 fixture（譯註 2）。

以下範例擴充了 TTestCaseFirst 並增加幾個測試 Delphi 集合類別 TStringList 的方法：

    interface

    uses
     TestFrameWork,
     Classes;  // needed for TStringList

    type
     TTestCaseFirst = class(TTestCase)
     private
       Fsl: TStringList;
     protected
       procedure SetUp; override;
       procedure TearDown; override;
     published
       procedure TestFirst;
       procedure TestSecond;
       procedure TestThird;
       procedure TestPopulateStringList;
       procedure TestSortStringList;
     end;

    ...

    procedure TTestCaseFirst.SetUp;
    begin
     Fsl := TStringList.Create;
    end;

    procedure TTestCaseFirst.TearDown;
    begin
      Fsl.Free;
    end;

    procedure TTestCaseFirst.TestPopulateStringList;
    var
     i: Integer;
    begin
     Check(Fsl.Count = 0);
     for i := 1 to 50 do    // Iterate
       Fsl.Add('i');
     Check(Fsl.Count = 50);
    end;

    procedure TTestCaseFirst.TestSortStringList;
    begin
      Check(Fsl.Sorted = False);
      Check(Fsl.Count = 0);
      Fsl.Add('You');
      Fsl.Add('Love');
      Fsl.Add('I');
      Fsl.Sorted := True;
      Check(Fsl[2] = 'You');
      Check(Fsl[1] = 'Love');
      Check(Fsl[0] = 'I');
    end;

### 測試套件（Test suites）

當你在測試一個真正有用的（non-trivial）應用程式時，你會想要建立一個以上的 TTestCase 衍生類別，欲將這些類別加到上層節點，你只需在 initialization 子句裡面註冊它們就行了，寫法跟上面的範例一樣。有時候，你可能想要更清楚地定義測試案例之間的結構關係，為此 DUnit 提供了建立測試套件的功能，它可以讓你在測試案例中包含其他的測試案例或測試套件（使用 Composite 樣式）。

如同在 TTestCaseFirst 測試案例中所顯示的，當算術運算的測試方法執行時，SetUp 和 TearDown 方法雖然有被呼叫但完全沒做任何事。其中有兩個處理字串串列的方法，最好能將它們分離成獨立的測試套件，做法是先把 TTestCaseFirst 拆成兩個類別，分別是 TTestArithmetic 與 TTestStringList：

    type
      TTestArithmetic = class(TTestCase)
      published
        procedure TestFirst;
        procedure TestSecond;
        procedure TestThird;
      end;

     TTestStringlist = class(TTestCase)
     private
       Fsl: TStringList;
     protected
       procedure SetUp; override;
       procedure TearDown; override;
     published
       procedure TestPopulateStringList;
       procedure TestSortStringList;
     end;

（當然啦，你也得更新這些方法的實作才行）

然後把 inistailization 的程式碼改成這樣：

    RegisterTest('Simple suite', TTestArithmetic.Suite);
    RegisterTest('Simple suite', TTestStringList.Suite);

### 逐步建立測試套件

TestFramework 單元的 TTestSuite 類別實作了測試套件，所以你可以用更明顯的方式建立測試階層：

下面的 UnitTests 函式會建立一個測試套件，並且在其中加入兩個測試類別：

    function UnitTests: ITestSuite;
    var
      ATestSuite: TTestSuite;
    begin
      ATestSuite := TTestSuite.Create('Some trivial tests');
      ATestSuite.AddTests(TTestArithmetic.Suite);
      ATestSuite.AddTests(TTestStringlist.Suite);
      Result := ATestSuite;
    end;

還有另一種寫法，跟上面的作用也是完全相同的：

    function UnitTests: ITestSuite;
    begin
      Result := TTestSuite.Create('Some trivial tests',
                             [
                              TTestArithmetic.Suite,
                              TTestStringlist.Suite
                              ]);
    end;

上面的範例是在呼叫 TTestSuite 的建構元時，把要加入的測試一併透過陣列傳遞過去。

使用上述任一種方式建立的測試套件，其註冊方式跟你之前註冊個別測試案例的方式是相同的：

    initialization
      RegisterTest('Simple Test', UnitTests);
    end.

當測試程式執行時，你就會在 GUITestRunner 視窗上看到新的樹狀階層。

## 其他功能

### 在主控台模式下執行測試

有時候，我們會想要在主控台模式下執行測試套件，比如說當你想要用一個 Makefile 執行整批的測試，這時候主控台模式就很有用。如要在主控台模式下執行測試，之前在 DPR 檔案裡面的 uses 子句中的 GUITestRunner 就要改成 TextTestRunner，並且加入條件編譯 {$APPTYPE CONSOLE} 或者在 IDE 裡點選 Project | Options | Linker | Generate console application 選項。

以下範例 Project1TestConsole.dpr 的專案原始碼：

    {$APPTYPE CONSOLE}

    program Project1TestConsole;
    uses
     TestFrameWork,
     TextTestRunner,
     Project1TestCases in 'Project1TestCases.pas';

    {$R *.RES}

    begin
      TextTestRunner.RunRegisteredTests;
    end.

程式執行的輸出結果會像這樣：

    --
    DUnit: Testing.
    ..F.E..
    Time: 0.20
    FAILURES!!!
    Test Results:
    Run: 5
    Failures: 1
    Errors: 1

    There was 1 error:
    1) TestThird: EDivByZero: Division by zero

    There was 1 failure:
    1) TestSecond

注意第三行的 '..F.E..' 字串，其中每一個句點（.）代表一項執行無誤的測試，'F' 表示測試失敗（failed），而 'E' 表示發生異常（exception）。

如果你希望當測試失敗時，讓 TextTestRunner 停止執行並且傳回一個非零的結束碼，你可以傳入一個rxbHaltOnFailures 參數值，像這樣：

    TextTestRunner.RunRegisteredTests(rxbHaltOnFailures);

當你使用 Makefile 來執行測試套件的時候，這些回傳的結束碼會很有用處。

### 擴充功能

The TextExtensions 單元中的類別是用來擴充 DUnit 框架的功能，大部分的類別使用了「四人幫」（GoF, Gang of Four）的 "Design Patterns" 書中所定義的 decorator 樣式。

#### TRepeatedTest

TRepeatedTest 類別允選你重複裝飾的測試許多次，例如，重複執行 TestFirst 測試案例中的 TTestArithmetic 10 次，你的程式可以這麼寫：

    uses
     TestFrameWork,
     TestExtensions, // needed for TRepeatedTest
     Classes;        // needed for TStringList

    ...

    function UnitTests: ITest;
    var
      ATestArithmetic : TTestArithmetic;
    begin
      ATestArithmetic := TTestArithmetic.Create('TestFirst');
      Result := TRepeatedTest.Create(ATestArithmetic, 10);
    end;

請注意 TTestArithmetic 的建構元：

    ATestArithmetic := TTestArithmetic.Create('TestFirst');

這裡我把要重複執行的測試方法的名稱傳遞給建構元，當然這個名稱一定不能寫錯，否則隨後執行時只能得到令人失望的結果。

如果你想要重複測試 TTestArithmetic 的全部方法，你可以把它們放在一個套件裡：

    function UnitTests: ITest;
    begin
      Result := TRepeatedTest.Create(ATestArithmetic.Suite, 10);
    end;

#### TTestSetup

TTestSetup 類別可以讓你為一個測試案例類別進行唯一一次的初始化設定（Setup 與 TearDown 方法是每次執行測試方法時就會被呼叫）。例如，如果你正在撰寫一組測試以驗證某些存取資料庫的程式碼，你可能會從 TTestSetup 衍生一個類別，並且利用它來開啟和關閉資料庫。

## 參考資料

位於 SourceForge 的 DUnit 首頁（https://sourceforge.net/projects/dunit/），有最新的原始碼，郵遞論壇，問答集...等。

Delphi 的終極測試工具 （ http://www.suigeneris.org/juanca/writings/1999-11-29.html），Juancarlo Añez 在這篇文章裡介紹了他設計的 DUnit 類別，此文最初公佈於 Borland 開發人員社群網站。

JUnit Test Infected: Programmers Love Writing Tests (http://www.junit.org/junit/doc/testinfected/testing.htm)，這是一篇介紹 JUnit 的好文章， DUnit 就是以此框架為基礎而發展出來的。

Simple Smalltalk Testing: With Patterns（http://www.xprogramming.com/testfram.htm），Kent Beck 最早的文件，比較適合熟悉 Smalltalk 的人閱讀。

~o~

#### 譯註

1. 部分檔案目錄在新版本裡面已經不存在了，例如：framework，故應以官方釋出的最新版的目錄結構為準。
2. Fixture 是 XP 術語。在一個 Test Case 裡面，負責初始化及清理的動作，對應到實作上就是 SetUp 與 TearDown 這兩個虛擬方法。每次執行測試時，會事先呼叫所有的 fixture 的 Setup 方法，並且在測試結束前呼叫 TearDown，我想也就因為這些是固定要執行的動作，所以取其名為 fixture，若要譯成中文，也許可以說成「固定裝置」或「固定機制」。 fixture 的典型用法是在 Setup 方法裡面配置資源，並且在 TearDown 裡面釋放資源，如果你正在測試存取資料庫的程式，並且希望測試的過程不會改變資料庫的內容，也可以在 TearDown 裡面將所有的交易撤回，或者撰寫交易補償的程式碼。