*
   原稿 (英文)

單元測試及先行測試開發

作者:Eric Gunnerson
Microsoft Corporation

2003 年 3 月 1 日

摘要:Eric Gunnerson 解釋了先行測試開發的概念,並提供操作範例來說明如何將這個準則實際運用到應用程式中。(列印共 9 頁)

請由 MSDN Online Code Center 下載 integerlist.exe 範例檔 (英文)。

如果您是屬於會看完我專欄結尾的自傳的少數民族,那麼就該知道我在當上程式設計部經理前,是 C# 程式編譯的首席測試工程人員,而在這之前則負責 C++ 程式編譯。這就是為什麼我非常熱衷於分析程式碼和找出可能錯誤的原因。

降低軟體錯誤率的方法之一是,擁有全力投注於軟體檢測工作的專業測試團隊。但糟糕的是,如果有測試團隊,就會存在一種不肯花更多時間檢查程式碼穩定性的傾向,這連經驗豐富的程式開發人員也是一樣。

軟體界常流傳一句話:「程式開發人員不該測試自己寫的程式碼。」 其中道理在於,因為程式開發人員對自己的程式碼非常瞭解,所以他們對於要如何正確地進行測試常常有偏見。這個說法雖然蠻合理的,但卻忽略了一項重點-如果程式開發人員不自己測試程式碼,那怎麼能確定程式碼會如預期執行呢?

答案很簡單:他們根本不確定。而程式開發人員寫出不能執行或只有部份能用的程式碼是個很嚴重的問題。他們通常只針對少數情況,而不是各種情況來驗證自己的程式碼能否順利執行。

發現錯誤

發現錯誤的情況如下:

  1. 程式開發人員剛寫好程式碼時。
  2. 程式開發人員想辦法讓程式碼順利執行時。
  3. 由團隊其他程式開發或測試人員發現。
  4. 進行規模較大的產品測試時。
  5. 由使用者發現。

如果在第 1 個情況下就發現錯誤,要修正很容易,成本也很低。越到後面才發現,所付出的成本就越高,而且要修正使用者發現的錯誤,可能得花上 100 或 1000 倍的成本。更別提使用者通常還必須等到下個版本出來時問題才能解決。

最理想的狀況是,程式開發人員在寫程式碼時就找出所有的錯誤。要找出錯誤,必須準備好在寫程式碼時可以執行的測試。現在就為您介紹撰寫測試的妙方。

先行測試開發

先行測試開發就是在寫程式碼「之前」先行測試。所有測試都沒問題的話,就表示程式碼可以順利執行,並且還能繼續確認後來加入的新功能是否穩定。

這個概念是 Kent Beck 在 1990 年代為 Smalltalk 寫 Smalltalk Unit 時首創。過去多年來,單元測試公用程式都已經可以在大多數環境中執行,其中一個很好用的 .NET Framework 公用程式叫 nUnit (英文)。

範例

我稍候會寫一個 IntegerList 類別,來說明先行測試開發的用法。這是原本儲存整數的 ArrayList 變型,因此沒有 Boxing 和 Unboxing 的負荷。

第一個步驟是建立主控台專案,並加入 IntegerList.cs 原始程式檔。要連結 nUnit 架構,我需要加入 nUnit 架構的參考。在我的電腦上,該參考位於 d:\program files\nUnit v2.0\bin

第二步是花點時間構想如何測試這個類別。這有點類似決定類別的功能,但重點則放在特定用法 (在清單中加上值 1,再檢查是否成功),而不是功能 (將項目加入清單)。若要建置類別,請先準備好一份要用的測試清單:

  1. 測試能否建構。
  2. 在清單中加入 2 個整數,並確定計算結果和項目都正確。
  3. 用更多項目重做一次。
  4. 將清單轉換成字串。
  5. 使用 foreach 來列舉清單。

這個範例有點反常,因為一開始我就很清楚要類別執行什麼工作。但大部份的類別都是逐步建置,而測試也應該隨類別一起增加。

現在就開始吧。先建立用來存放所有測試的 C# 類別,檔名為 IntegerListTest.cs。這是第一個測試檔案:

using System;
using System.Collections;
using NUnit.Framework;

namespace IntegerList
{
    /// <summary>
    /// Summary description for IntegerClassTest.
    /// </summary>
    [TestFixture]
    public class IntegerClassTest
    {
        [Test]
        public void ListCreation()
        {
            IntegerList list = new IntegerList();
            Assertion.AssertNotNull(list);
        }
    }
}

[TestFixture] 屬性將此類別標記為測試類別,且 [Test] 屬性將 ListCreation() 方法標記為測試方法。在這個方法中,我先建立一個清單,然後再用 Assertion 類別來測試物件已建立完成。

啟動 nUnit GUI 測試程式、開啟可執行檔,然後執行測試。結果顯示如下。

點按此圖即可將其放大

[圖 1] nUnit GUI 顯示測試結果

這表示所有的測試都通過了。現在我想加入一些實際功能。首先,我希望能將整數加入清單。下列是相關測試:

        [Test]
        public void TestSimpleAdd()
        {
            IntegerList list = new IntegerList();
            list.Add(5);
            list.Add(10);
            Assertion.AssertEquals(2, list.Count);
            Assertion.AssertEquals(5, list[0]);
            Assertion.AssertEquals(10, list[1]);
        }

在這個測試中,我選擇同時測試兩件事:

  • 清單能保持正確的 Count 屬性。
  • 清單能存放兩個項目。

有些提倡測試導向開發的人主張儘可能細分各種測試,但我認為測試計算結果而不測試項目數目很奇怪,所以我選擇這麼做。

編譯此程式碼失敗,因為 IntegerList 類別沒有方法,所以我加入 Stub 來進行編譯:

        public int Count
        {
            get
            {
                return -1;
            }
        }

        public void Add(int value)
        {
        }

        public int this[int index]
        {
            get
            {
                return -1;
            }
        }

然後再重頭執行測試,而測試結果是紅色,表示測試失敗。這是好現象,代表我的測試真的管用,而且還找到錯誤。那麼我就可以進行實作了。先從簡單但費時的範例開始:

        public int Count
        {
            get
            {
                return elements.Length;
            }
        }

        public void Add(int value)
        {
            int newIndex;
            if (elements != null)
            {
                int[] newElements = new int[elements.Length + 1];
                for (int index = 0; index < elements.Length;
                     index++)    
                {
                    newElements[index] = elements[index];
                }
                newIndex = elements.Length;
                elements = newElements;
            }
            else
            {
                elements = new int[1];
                newIndex = 0;
            }
            elements[newIndex] = value;
        }

        public int this[int index]
        {
            get
            {
                return elements[index];
            }
        }

現在我已經完成一小部份類別,還剩下確保測試類別正確執行的測試,但測試完成的項目很少。接下來,我要寫一個能檢查 1000 個項目的測試。

        [Test]
        public void TestOneThousandItems()
        {
            list = new IntegerList();

            for (int i = 0; i < 1000; i++)
            {
                list.Add(i);
            }

            Assertion.AssertEquals(1000, list.Count);
            for (int i = 0; i < 1000; i++)
            {
                Assertion.AssertEquals(i, list[i]);
            }
        }

這個測試沒問題,所以不必做任何變更。

加入 ToString() 方法

接著,我會加入程式碼來測試 ToString() 能否正確執行:

        [Test]
        public void TestToString()
        {
            IntegerList list = new IntegerList();
            list.Add(5);
            list.Add(10);
            string t = list.ToString();
            Assertion.AssertEquals("5, 10", t.ToString());
        }

結果失敗。下列是使其通過測試的程式碼:

        public override string ToString()
        {
            string[] items = new string[elements.Length];
            for (int index = 0; index < elements.Length; index++)
            {
                items[index] = elements[index].ToString();
            }
            return String.Join(", ", items);
        }

啟用 Foreach

很多使用者會很希望能 Foreach 我的清單。作法是將 IEnumerable 實作在類別上,再定義一個實作 IEnumerable 的不同類別。首先,測試執行:

        [Test]
        public void TestForeach()
        {
            IntegerList list = new IntegerList();
            list.Add(5);
            list.Add(10);
            list.Add(15);
            list.Add(20);

            ArrayList items = new ArrayList();

            foreach (int value in list)
            {
                items.Add(value);
            }

            Assertion.AssertEquals("Count", 4, items.Count);
            Assertion.AssertEquals("index 0", 5, items[0]);
            Assertion.AssertEquals("index 1", 10, items[1]);
            Assertion.AssertEquals("index 2", 15, items[2]);
            Assertion.AssertEquals("index 3", 20, items[3]);
        }

我也使 IntegerList 實作 IEnumerable

        public IEnumerator GetEnumerator()
        {
            return null;
        }

這在測試時產生例外狀況。為了能正確實作,我使用巢狀類別列舉值。

    class IntegerListEnumerator: IEnumerator
    {
        IntegerList    list;
        int index = -1;

        public IntegerListEnumerator(IntegerList list)
        {
            this.list = list;
        }
        public bool MoveNext()
        {
            index++;
            if (index == list.Count)
                return(false);
            else
                return(true);
        }
        public object Current
        {
            get
            {
                return(list[index]);
            }
        }
        public void Reset()
        {
            index = -1;
        }
    }

此類別傳遞指標給 IntegerList 物件,然後僅傳回物件項目。

這樣清單就可 Foreach,但可惜 Current 屬性的型別為物件,每個值都會經過封裝才會傳回。解決方法是使用模式 (Pattern) 架構方法,它看起來和目前的方法一模一樣,但卻以 GetEnumerator() 傳回實際類別 (而非 IEnumerator),且此類別的 Current 屬性型別為 int

但完成上述作業後,我想確認在不支援模式架構方法的語言環境中,還是可以使用介面架構方法。所以我複製最後寫的那個測試,並修改 Foreach 以轉換成介面。

            foreach (int value in (IEnumerable) list)

只要稍作變更,清單就能在兩個案例中執行。請參閱範例程式碼,以取得詳細資訊和更多測試。

建議事項

我只花了大概一小時就寫完這篇文章的程式碼和文字。預先寫好測試的好處在於,既然已經很清楚該如何編寫類別才能通過測試,那寫起程式碼就較能得心應手。

這個方法最適合用在小規模、逐次累加的測試。我鼓勵您在小型專案上嘗試。先行測試開發是稱為「靈活方法學 (Agile Methodology)」的一部份,有關「靈活程式開發方式 (Agile Development)」的詳細資訊,請造訪 http://www.agilealliance.com/home (英文)。

下期預告

最近我正在玩 Microsoft DirectX® 9,要是它成為下個月的主題,可別太訝異。


Eric Gunnerson 是 Visual C# 編譯器團隊的專案經理,C# 設計團隊過去的成一員,以及《A Programmer's Introduction to C#, 2nd Edition》(英文) 的作者。他的程式設計生涯長到知道什麼是 8 英吋的磁片,並能夠用一手裝載磁帶。空閒時,他都忙著進行「不可能的任務」。