【單元測試的藝術】Chap 4: 使用模擬物件驗證互動


目錄

  • PART I: 入門
    • Chap 1: 單元測試基礎
    • Chap 2: 第一個單元測試
  • PART II: 核心技術
    • Chap 3: 透過虛設常式解決依賴問題
    • Chap 4: 使用模擬物件驗證互動
    • Chap 5: 隔離(模擬)框架
    • Chap 6: 深入了解隔離框架
  • PART III: 測試程式碼
    • Chap 7: 測試階層和組織
    • Chap 8: 好的單元測試的支柱
    • Chap 9: 在組織中導入單元測試
    • Chap 10: 遺留程式碼
    • Chap 11: 設計與可測試性

一、基於值、狀態與互動的測試

工作單元可能有三種最終結果:回傳值、改變系統狀態、與相依物件之間的互動。目前為止,我們已經針對前面兩種(回傳值、改變系統狀態)寫過測試,這一章,我們要學會怎麼測試第三種——與相依物件之間的互動。

這裡將驗證你的測試目標物件是否正確地呼叫了其他物件。這個時候需要用到「模擬物件」來驗證。

首先,先定義互動測試(interaction testing)是什麼,並搞清楚它與基於回傳值、系統狀態的測試有何不同。

互動測試的定義:互動測試是針對一個物件如何向其他物件發送訊息(呼叫方法)的測試。如果一個特定的工作單元的最終結果,是呼叫另外一個物件,你就需要進行互動測試。

互動測試針對的是第三種結果類型:呼叫相依的第三方物件。基於回傳值的測試,驗證一個函數所傳回的值;基於狀態的測試,則透過可取得的系統狀態,驗證被測試系統狀態的改變是否符合預期。

你也可以將互動測試看成是 動作驅動(action-driven)測試。動作驅動測試是指測試一個物件所採取的特定動作(例如對另一個物件發送訊息)。

請記得盡量將互動測試作為最後的一個選擇。 你應該優先考慮能否使用前面兩種類型(基於回傳值或系統狀態)的測試,因為互動測試會讓很多事情變得複雜。

為了理解互動測試的優缺點,我們來看個例子。假設有一個灌溉系統,你對這個系統如何灌溉庭院的樹進行了些設定:一天要澆幾次,每次要澆多少水。

以下是測試這個系統是否正確工作的兩種方式:

  • 基於狀態的整合測試(是的,是整合測試而非單元測試):讓灌溉系統運作 12 小時,這期間它應該給樹澆了很多次水,這個時間結束後,檢查樹被灌溉的狀態,土壤是否足夠潮濕,樹是否健康,葉子是否青翠等。這個測試也許很難執行,但是只要能執行,你就知道灌溉系統是否工作正常。
  • 互動測試:在每個水管的末端,安裝一個設備,紀錄什麼時間有多少水流過。在每天結束時,檢查這個設備紀錄的次數是否正確,每次的水量是否符合預期,而不用去檢查樹的情況。實際上,你根本不需要樹,可以更進一步,修改灌溉系統上的系統時鐘(使其成為一個虛設常式),讓系統以為灌溉時間已經到了,在你所選擇的任意時間進行灌溉。這樣就不需等待(此例為 12 小時)就能知道系統是否正常運作。

紀錄灌溉系統的設備是一個假的水龍頭,也可以說它是一個虛設常式,但這是一種更聰明的虛設常式物件,它是能紀錄被呼叫次數的物件,因此你可以透過驗證它來判斷測試是否通過,這差不多是模擬物件的定字。另一方面,那個用來替換時間的假時鐘是一個虛設常式,因為它只是假裝工作,模擬時間讓你更方便地測試系統。

模擬物件的定義:模擬物件是系統中的假物件,它可以拿來驗證被測試物件是否如預期般呼叫這個假物件,因此來使得單元測試執行成功或失敗。通常每個測試裡最多只會有一個模擬物件。


假物件的定義:假物件是通用的名詞,可以拿來描述一個虛設常式物件或模擬物件(手刻或非手刻),因為虛設常式物件和模擬物件看上去都很像真實的物件。一個假物件,究竟是虛設常式物件還是模擬物件,取決於它在目前測試裡的使用方式。如果這個假物件是拿來驗證物件之間的互動(對它進行驗證),那它就是模擬物件,否則就是虛設常式物件。


二、模擬物件和虛設常式的差異

你用一個虛設常式物件來取代一個物件,以確保你能對待測試目標進行驗證。下圖說明了虛設常式物件和被測試類別之間的互動。

alt text

當你使用虛設常式物件時,你是對待測試類別進行驗證。虛設常式物件只是輔助讓你對待測試類別的測試更加順利。


虛設常式物件跟模擬物件最根本的差異是:虛設常式物件不會導致測試失敗,而模擬物件可以。

要辨識是否使用了虛設常式物件,最簡單的方式是:虛設常式物件不會導致測試失敗。因為測試是針對待測試類別進行驗證,而非虛設常式物件。

另一方面,測試會針對模擬物件進行驗證,確定測試是否成功或失敗。下圖說明了測試和模擬物件之間的互動。請注意,測試是針對這個模擬物件來進行驗證,而非針對待測試類別驗證。

alt text

待測試類別與模擬物件進行互動,而模擬物件會記錄所有的互動資訊。測試則針對模擬物件進行驗證,以確認測試是否通過。


三、手刻模擬物件的簡單範例

建立和使用模擬物件,跟虛設常式物件很像,只是模擬物件比虛設常式多做一件事:它會紀錄所有互動的歷史,這些紀錄之後用來驗證是否符合預期(exception)

先針對我們的老朋友 LogAnalyzer 類別增加一個新的需求。這次,LogAnalyzer 需要和一個外部的 web 服務介接,每次 LogAnalyzer 遇到一個過短的檔名時,這個 web 服務就會收到一個錯誤訊息。但是,目前這個 web 服務還沒開發完畢,就算已經開發完了,使用這個 web 服務會導致測試時間過長。

因此,你需要重構設計,新增一個介面,之後透過這個介面來建立一個模擬物件。這個介面只包含你需要呼叫的 web 服務方法。下圖呈現如何在測試中使用你的模擬物件 FakeWebService。

alt text

你的測試程式將建立一個 FakeWebService 模擬物件,來紀錄 LogAnalyzer 送給它的訊息,接著我們會針對 FakeWebService 來進行驗證。


首先,先擷取一個簡單的介面。被測試類別可以使用這個介面,而不是直接呼叫 web 服務。

    public interface IWebService
    {
        void LogError(string message);
    }

這個介面既可以拿來建立虛設常式物件,也可以拿來建立模擬物件,避免在單元測試中出現一個無法控制的相依物件。

接下來,建立模擬物件本身。這個物件看起來可能很像虛設常式物件,但是它包含了一些額外的程式碼,變成一個模擬物件。

    public class FakeWebService: IWebService
    {
        public string LastError;
        pubic void LogError(string message)
        {
            LastError = message;
        }
    }

和虛設常式物件一樣,這個手刻的類別實作了一個介面,但它還額外儲存了一些狀態資訊,這樣測試就可以對這些資訊來進行驗證,確認模擬物件有如預期被呼叫。它現在還不是個模擬物件,只有當它在測試中被作為模擬物件使用時,它才成為模擬物件。


下列程式碼清單,說明如何在測試程式中使用模擬物件:

    [Test]
    public void Analyze_TooShortFileName_CallsWebService()
    {
        FakeWebService mockService = new FakeWebService();

        LogAnalyzer log = new LogAnalyzer(mockService);
        string tooShortFileName = "abc.ext";
        log.Analyze(tooShortFileName);

        StringAssert.Contains("FileName too short: abc.ext", mockService.LastError); // 針對模擬物件進行驗證
    }

    public class LogAnalyzer
    {
        private IWebService service;
        public LogAnalyzer(IWebService service)
        {
            this.service = service;
        }
        public void Analyze(string fileName)
        {
            if (fileName.Length < 8)
            {
                service.LogError("FileName too short: " + fileName); // 在產品程式中紀錄檔名過短的錯誤
            }
        }
    }

在測試程式中,是對模擬物件進行驗證,而非 LogAnalyzer 類別,因為測試是針對「LogAnalyzer 與 web 服務之間的互動」。這裏使用到了依賴注入(DI),但這次的模擬物件還能決定測試究竟是成功還失敗。

還有一點要注意:驗證的功能不是直接寫在模擬物件裡面。原因如下:

  • 你希望其他測試案例能夠驗證別的東西,能重用這個模擬物件。
  • 如果驗證是寫死在手刻的假物件類別裡面,那麼閱讀測試程式的人無法看到具體的驗證動作是什麼。這種作法對測試程式隱藏了關鍵的資訊,降低了測試的可讀性和維護性。

四、同時使用模擬物件和虛設常式

來思考一下更複雜的問題。這次 LogAnalyzer 不只是要呼叫 web 服務,而且如果 web 服務拋出錯誤,LogAnalyzer 還需要把這個錯誤紀錄在另一外部相依裡,例如將錯誤用 email 發送給服務管理員。如下圖所示:

alt text

LogAnalyzer 有兩個外部相依:web 服務與 email 服務。

以下是 LogAnalyzer 裡需要測試的邏輯:

    if (fileName.Length < 8)
    {
        try
        {
            service.LogError("FileName too short:" + fileName);
        }
        catch (Exception e)
        {
            email.SendEmail("a", "subject", e.Message);
        }
    }

下列是需要解決的問題:

  • 如何替換掉 web 服務?
  • 如何模擬來自 web 服務所引發的例外,以便測試有如預期般呼叫 email 服務?
  • 如何判斷呼叫 email 服務的過程是否正確,或是真的有呼叫 email 服務的動作?

解決前兩個問題,可以使用虛設常式物件來取代 web 服務;解決第三個問題,可以使用一個模擬物件來取代 email 服務。


這樣一來,測試就會有兩個物件。一個是 email 服務的模擬物件,用來驗證呼叫 email 服務時,傳入的參數與次數是否正確。另一個是用來模擬 web 服務拋出例外的虛設常式物件(你不會對 web 服務物件進行驗證,只用它來確保測試順利進行)。

alt text

測試程式中使用虛設常式物件來模擬 web 服務拋出例外,然後用模擬物件來確認 email 服務是否有被正確的呼叫。測試的情境主要是針對 LogAnalyzer 與其他物件之間的互動是否符合預期。

    public interface IEmailService
    {
        void SendEmail(string to, string subject, string body);
    }

    public class LogAnalyzer2
    {
        public LogAnalyzer2(IWebService service, IEmailService email)
        {
            Email = email;
            Service = service;
        }
        public IWebService service
        {
            get;
            set;
        }
        public IEmailService Email
        {
            get;
            set;
        }
        public void Analyze(string fileName)
        {
            if (fileName.Length < 8)
            {
                try
                {
                    Service.LogError("FileName too short:" + fileName);
                }
                catch 
                {
                    Email.SendEmail("someone@somewhere.com", "can't log", e.Message);
                }
            }
        }
    }

    [TestFixture]
    public class LogAnalyzer2Tests
    {
        [Test]
        public void Analyze_WebServiceThrows_SendsEmail()
        {
            FakeWebService stubService = new FakeWebService();
            stubService.ToThrow = new Exception("fake exception");
            FakeEmailService mockEmail = new FakeEmailService();

            LogAnalyzer2 log = new LogAnalyzer2(stubService, mockEmail);
            string tooShortFileName = "abc.ext";
            log.Analyze(tooShortFileName);

            StringAssert.Contains("someone@somewhere.com", mockEmail.To);
            StringAssert.Contains("fake exception", mockEmail.Body);
            StringAssert.Contains("can't log", mockEmail.Subject);
        }
    }

    public class FakeWebService: IWebService
    {
        public Exception ToThrow;
        public void LogError(string message)
        {
            if (ToThrow != null)
            {
                throw ToThrow;
            }
        }
    }

    public class FakeEmailService: IEmailService
    {
        public string To;
        public string Subject;
        public string Body;
        public void SendEmail(string to, string subject, string body)
        {
            To = to;
            Subject = subject;
            Body = body;
        }
    }

如果我們希望在第一個驗證失敗時,繼續往下執行,通常代表需要將測試拆成多個。或是建立一個 EmailInfo 物件。

    class EmailInfo
    {
        public string Body;
        public string To;
        public string Subject;
    }

    [Test]
    public void Analyze_WebServiceThrows_SendsEmail()
    {
        FakeWebService stubService = new FakeWebService();
        stubService.ToThrow = new Exception("fake exception");
        FakeEmailService mockEmail = new FakeEmailService();

        LogAnalyzer2 log = new LogAnalyzer2(stubService, mockEmail);
        string tooShortFileName = "abc.ext";
        log.Analyze(tooShortFileName);
        EmailInfo expectedEmail = new EmailInfo { Body = "fake exception", To = "someone@somewhere.com", Subject = "can't log" }; // 建立期望物件

        Assert.AreEqual(expectedEmail, mockEmail.email); // 也可以用 Assert.AreSame 比較真實物件
    }

    public class FakeEmailService: IEmailService
    {
        public EmailInfo email = null;
        public void SendEmail(EmailInfo emailInfo)
        {
            email = emailInfo;
        }
    }

五、每個測試只用一個模擬物件

如果一個測試只測一件事,測試中應該最多就只有一個模擬物件。

過度指定(overspecification) 是指過度指定在測試中應該要發生事情的行為,而這些事情事實上對測試來說無關緊要。


六、假物件鍊:用虛設常式物件來產生模擬物件或其他虛設常式物件

有的時候你需要用一個假物件,來回傳另一個假物件。如:

    IServiceFactory factory = GetServiceFactory();
    IService service = factory.GetService();

或是像這樣

    string connstring = GlobalUtil.Configuration.DBConfiguration.ConnectionString;

物件鍊是個很強大好用的技術,但也許將程式碼重構成下面會比較好:

    string connstring = GetConnectionString();
    protected virtual string GetConnectionString()
    {
        return GlobalUtil.Configuration.DBConfiguration.ConnectionString;
    }

七、手刻模擬物件和虛設常式物件的問題

使用手刻模擬物件和虛設常式物件,會有下列的問題:

  • 撰寫模擬物件和虛設常式物件需要花很多時間
  • 如果類別和介面有很多方法、屬性或事件,就很難為它手刻模擬物件和虛設常式物件。
  • 要保留模擬物件多次被呼叫的狀態,你需要在手刻的假物件中寫許多樣板程式
  • 如果要驗證呼叫端針對一個方法所傳入的多個參數全部是正確的,需要寫多個驗證語法,非常笨拙。
  • 難以在測試中重用模擬物件或虛設常式物件的程式碼
  • 一個假物件可以同時是模擬物件又是虛設常式嗎?這種情況很少

八、小結

本章介紹了虛設常式跟模擬物件的差別。虛設常式物件不會使測試執行失敗,只是拿來模擬各種情境。

一個測試內,同時使用模擬物件和虛設常式物件是一項強大的技術,但是必須注意一個測試中不應該存在多個模擬物件(過度指定)。

對於大型介面或複雜的互動測試情境,手刻模擬物件和虛設常式物件很不方便。下一章,將會說明更好的方式,討論隔離(模擬框架),這種框架在執行時期能自動產生假物件。

#Unit Test #單元測試的藝術





也許你是單元測試甚至 TDD 愛好者,也許你著墨過一點單元測試,也許你是個剛開始寫程式的新手,不論你是誰,你都必須讀讀這本「單元測試的藝術」。 這是一本由 Roy Osherove 撰寫,針對靜態程式語言最經典的單元測試書籍。這個系列文會慢聊這本單元測試聖經,感受超層級的藝術。

留言討論