iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0

只針對一個關注點測試

昨天提到虛設常式與模擬物件的差異,兩者之間之差在驗證的時候如果是用該假物件驗證,則為模擬物件;反之,則為虛設常式。此外,每一次的測試都應該只有一個關注點(換言之,應只有一個模擬物件),若一個測試含有多個驗證(多個模擬物件),則會引發一些疑慮,舉個簡單的例子如下:

[Test]
public void CheckSumResult()
{
    // Arrange
    var sum0 = 0;
    var sum1 = 0;
    var sum2 = 0;
    
    // Act
    sum0 = 1001 + 1 + 2;
    sum1 = 1 + 1001 + 2;
    sum2 = 1 + 2 + 1001;
    
    // Assert
    Assert.AreEqual(1004, sum0);
    Assert.AreEqual(1004, sum1);
    Assert.AreEqual(1004, sum2);
}

OK,寫到這邊可以看出來我們在測試程式碼 CheckSumResult 做了三次的總和,順序互相調換,理論上總和應該都是1004;但假設今天這個互相調換的程式碼沒寫好,變成以下的情況:

[Test]
public void CheckSumResult()
{
    // Arrange
    var sum0 = 0;
    var sum1 = 0;
    var sum2 = 0;
    
    // Act
    sum0 = 1001 + 1 + 2;
    sum1 = 1 + 1 + 2;
    sum2 = 1 + 2 + 1001;
    
    // Assert
    Assert.AreEqual(1004, sum0);
    Assert.AreEqual(1004, sum1);
    Assert.AreEqual(1004, sum2);
}

在 sum0 到 sum1 的時候,1001 與 1 應該要互相調換,但不知為何 1001 沒有成功覆寫第二個位置,導致 sum1 的值不符合預期,就會出錯;但是,第一與三個總和應該還是要出現正確,卻無法從這個測試得知。其原因在於 NUnit 的機制是在驗證發生失敗時,會拋出一個 AssertException 例外,NUnit 測試執行器會攔截這個例外,認為目前這個測試方法失敗了,就不會繼續執行下面的程式碼。同理,假設有人把昨天兩個的測試寫在一起,改寫成以下的例子:

using NUnit3;

[TestFixture]
public class EmailWithLogSystemUnitTests
{
    [Test]
    public void SendFunction_Fail()
    {
        // Arrange
        StubEmailSerivce stubEmailService = new StubEmailSerivce();
        FakeLogSerivce mockLogService = new FakeLogSerivce();
        
        EmailWithLogSystem EmailWithLogService = new EmailWithLogSystem(stubEmailService, mockLogService);
        
        // Act
        var result = EmailWithLogService.SendFunction("Test@abc.com.tw", "Test Demo");
        
        // Assert
        Assert.AreEqual("Fail", result);
        Assert.AreEqual("Test@abc.com.tw is not send yet!", mockLogService.logMessage);
    }
}

public class StubEmailSerivce : IEmailService
{
    public string SendEmail(mailAddress, mailMessage)
    {
        return "Fail";
    }
}

public class FakeLogSerivce : ILogService
{
    public string logMessage;
    
    public string Log(string LogMessage)
    {
        logMessage = LogMessage;
    }
}

這段測試碼出現了兩個問題點,第一個是出現多個驗證,這樣的壞處是若改動程式碼,如下:

public class StubEmailSerivce : IEmailService
{
    public string SendEmail(mailAddress, mailMessage)
    {
        // Fail 改成 Success
        // return "Fail";
        return "Success";
    }
}

第一眼看到這段改動,很難在第一時間看出第二個驗證是否有出錯,出錯的問題點在哪。此外,第二個問題點是有假物件同時擔任虛設常式及模擬物件。因有多個驗證,在第一個驗證的時候,他是扮演虛設常式;但在第二個驗證的時候,又擔任了模擬物件,角色呈現曖昧不清的情況,會造成程式碼閱讀上的困難,形成維護上的成本。


過度指定

在單元測試的藝術中,過度指定是指

對一個測試單元該如何完成內部行為進行了假設,而不是只檢查最終行為的正確性。

好,我相信看到這會覺得好抽象XDDD,先列出單元測試的藝術中提出的幾種情況,再舉個簡單的例子。

  • 測試對一個被測試物件的純內部狀態進行驗證
  • 測試中使用多個模擬物件
  • 測試在需要使用虛設常式物件時,使用模擬物件
  • 測試在不必要的情況下,指定順序或使用了精準的參數匹配器

那我們以「測試在需要使用虛設常式物件時,使用模擬物件」的情境並搭配昨天的狀況來撰寫,如下:

using NUnit3;

[TestFixture]
public class EmailWithLogSystemUnitTests
{
    [Test]
    public void SendFunction_CatchSendResult_Success()
    {
        // Arrange
        MockEmailSuccessSerivce mockEmailService = new MockEmailSuccessSerivce();
        StubLogSerivce stubLogService = new StubLogSerivce();
        
        EmailWithLogSystem EmailWithLogService = new EmailWithLogSystem(mockEmailService, stubLogService);
        
        // Act
        EmailWithLogService.SendFunction("Test@abc.com.tw", "Test Demo");
        
        // Assert
        Assert.AreEqual("Success", mockEmailService.SendResult);
    }
}

public class MockEmailSuccessSerivce : IEmailService
{
    public string SendResult

    public string SendEmail(mailAddress, mailMessage)
    {
        SendResult = "Success";
        
        return "Success";
    }
}

public class StubLogSerivce : ILogService
{
    public string logMessage;
    
    public string Log(string LogMessage)
    {
        logMessage = LogMessage;
    }
}

昨天提到當我們 SendEmail 的時候,我們去驗證方法提供的回傳值;然而,今天我們卻以模擬物件的寫法去驗證是不是有做 SendEmail 這個動作,當之後若規格發生改變,改變了回傳值,我們也需相對應改模擬物件的方法,這樣使得程式碼維護變困難卻沒有任何測試效益。


假物件鏈

好,那接下來又要提更抽象的東西 XD,假設我們今天新增了一個虛設常式,然後這個虛設常式又可以再新增虛設常式,甚至可以新增要驗證的模擬物件,形成一個假物件鏈(這難道是傳說中的假物件俄羅斯娃娃套餐!?XDD)。

舉個例子:

public class EmailWithLogServiceFactory()
{
    public class StubEmailSuccessSerivce : IEmailService
    {
        public string SendEmail(mailAddress, mailMessage)
        {
            return "Success";
        }
    }
    
    public class StubEmailFailSerivce : IEmailService
    {
        public string SendEmail(mailAddress, mailMessage)
        {
            return "Fail";
        }
    }

    public class StubLogSerivce : ILogService
    {
        public string logMessage;

        public string Log(string LogMessage)
        {
            logMessage = LogMessage;
        }
    }
    
    public class MockLogSerivce : ILogService
    {
        public string logMessage;

        public string Log(string LogMessage)
        {
            logMessage = LogMessage;
        }
    }
}

可以看出來,我們把 Day-13 所用到的模擬物件都彙整在 EmailWithLogServiceFactory 裡面。實務上,在工廠方法(Factory Method Pattern)的設計框架中,要撰寫測試的話就很容易以這種形式撰寫。因此,會隨著不同的設計方式而決定假物件的設計模式,進而衍生不同的假物件鏈。


到這邊算是把假物件做個簡單的概述,撰寫單元測試的核心很大一部分就是看假物件怎麼設計,而決定了後續假物件的難易程度。相信如果這幾天都有在看的人會發現一件事情,我們花很大的篇幅,在撰寫假物件的程式碼;其實這會衍生很多問題點(擷取自單元測試的藝術):

  • 撰寫模擬物件和虛設常式物件需要花很多時間
  • 如果類別和介面有很多方法、屬性或事件,就很難為它手刻模擬物件和虛設常式物件
  • 要保留模擬物件多次被呼叫的狀態,你需要在手刻的假物件中寫許多樣板程式
  • 如果要驗證呼叫端對一個方法所傳入的多個參數全都是正確的,需要寫多個驗證語法,非常笨拙
  • 難以在測試中重用模擬物件或虛設常式物件的程式碼。一般的程式碼還能用,但是一旦介面有兩個或三個以上的方法 需要實作,程式碼的維護就會顯得異常麻煩。

因此,接下來明天終於要介紹另一個單元測試很大的核心概念——隔離框架(isolation framework),教你如何產製動態虛設常式物件(dynamic stub)和動態模擬物件(dynamic mock)。


上一篇
Day 13-假物件 (Fake) - 模擬物件 (Mock)-2 (核心技術-5)
下一篇
Day 15-隔離框架 (isolation Framework) - 概念基本介紹 (核心技術-7)
系列文
單元測試從入門到進階之路 (以 C# NUnit 3 X NSubstitute 為例)30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言