C# Test Legacy Code(4)Unit Test with Static Functions by joeychen | CodeData
top

C# Test Legacy Code(4)Unit Test with Static Functions

分享:

C# Test Legacy Code(3)Compare Object Equality << 前情

前言

前面幾篇文章介紹了常見棘手的單元測試問題,包含了:

  1. 直接相依 File I/O 的問題
  2. 直接相依簡單工廠 static function 的問題
  3. 單元測試中比較物件屬性值是否相等的問題

這篇文章所要解決的問題類型,也是 legacy code 常見的壞味道,直接與 static function 耦合。這與上面的第二篇直接相依於簡單工廠的 static function 有一點像,但目的卻是不同。

一樣透過簡單且不帶太多風險的手法(我喜歡稱它為金蟬脫殼),來解決下面的問題:

  • 因為耦合 static function 而無法進行 isolated unit test 的問題
  • 加上測試之後,針對 static function 解耦的設計

金蟬脫殼

使用 Static 的迷思

過去很常聽到開發人員一些錯誤的迷思,導致出現一堆直接與 static 耦合的理由,例如:

  • 不用 new 就可以節省記憶體的使用:但需使用 static 存放的資訊,會一直活在 process memory 中,無法被 gc 回收。整體來看究竟是節省還是佔住記憶體不放,還很難說。悲慘的是,實務上往往是後者居多。除非該 static 是一般的常見的 utility helper。
  • 不用 new 寫起來比較快:在 Visual Studio 裡面強大的 intellisense support 底下,我實在搞不懂會有差多少,何況一般 new instance 的動作,應該都被封裝起來,在 context 端隔離初始化物件與使用物件,能為未來帶來相當大的擴充性,職責也有適當的分離。
  • 效能比較好:你在開玩笑吧?你演算法沒寫好導致不小心多跑了幾個迴圈或不必要的判斷式,SQL 沒中 index 之類常見的問題,對效能的影響大概是這的幾千幾萬倍以上,別再迷信獅子的鬃毛了。

直接相依 static function 的主要問題是:

  • 無謂地佔住記憶體過久
  • 直接耦合造成無法獨立進行單元測試
  • 無法享用物件導向設計的好處(繼承的重用與擴充、介面的可抽換性、多型的擴充性)
  • race condition
    • 尤其在 Dao 中將 connection 資源宣告成 static 存放更是一件災難,一旦到線上多人一起使用,就會冒出一堆 connection 佔用/使用中的 exception,更可怕的是往往這種 legacy code 還會把 exception 吃掉,假裝天下太平

Example

舉例來說,現在有個 AuthService 的 IsValid() 要使用者傳入 account 與 password 驗證是否合法。程式碼如下(github 版本):

public class AuthService
{
    public bool IsValid(string account, string password)
    {
        string passwordFromDao = ProfileDao.GetPassword(account);
        string token = ProfileDao.GetToken(account);

        var validPassword = passwordFromDao + token;

        var isValid = validPassword == password;
        return isValid;
    }
}

public static class ProfileDao
{
    //just simulate data source
    private static Dictionary<string, string> fakePasswordDataset = new Dictionary<string, string>
    {
        {"joey","1234"},
        {"demo","!@#$"},
    };

    internal static string GetPassword(string account)
    {
        if (!fakePasswordDataset.ContainsKey(account))
        {
            throw new Exception("account not exist");
        }

        return fakePasswordDataset[account];
    }

    internal static string GetToken(string account)
    {
        //just for demo, it's insecure
        var seed = new Random((account.GetHashCode() + (int)DateTime.Now.Ticks) & 0x0000FFFF);
        var result = seed.Next(0, 999999);
        return result.ToString("000000");
    }
}

AuthService.IsValid() 中,透過傳入的 account 取得存放在 ProfileDao 中的 Passoword 與 Token 亂數。但這邊的作法就是偷懶,直接將 ProfileDao 設計成 static class 並讓 AuthService 直接與 static function 耦合。這樣會導致下面的測試程式幾乎永遠是 failed 的狀態。

[TestClass]
public class AuthServiceTest
{
    [TestMethod]
    public void Test_IsValid_joey_1234666666_Should_Return_True()
    {
        var target = new AuthService();
        var account = "joey";
        var password = "1234666666";

        var actual = target.IsValid(account, password);

        var expected = true;

        //because of random, it should almost always assert failed
        actual.ShouldBe(expected);
    }
}

Step 1-在 static class 中建立 interface 轉接

這一步驟相當簡單,只要宣告一個 interface 的 field ,並定義其方法簽章與目前的 static function 一模一樣,接著將所有眼前會使用到的 static function 都轉接該 interface 即可。程式碼如下(github 版本):

public static class ProfileDao
{
    private static IProfileDao _profileDao;

    ////just simulate data source
    //private static Dictionary<string, string> fakePasswordDataset = new Dictionary<string, string>
    //{
    //  {"joey","1234"},
    //  {"demo","!@#$"},
    //};

    internal static string GetPassword(string account)
    {
        // 轉接到 interface 的方法
        return _profileDao.GetPassword(account);

        //if (!fakePasswordDataset.ContainsKey(account))
        //{
        //  throw new Exception("account not exist");
        //}

        //return fakePasswordDataset[account];
    }

    internal static string GetToken(string account)
    {
        // 轉接到 interface 的方法
        return _profileDao.GetToken(account);

        ////just for demo, it's insecure
        //var seed = new Random((account.GetHashCode() + (int)DateTime.Now.Ticks) & 0x0000FFFF);
        //var result = seed.Next(0, 999999);
        //return result.ToString("000000");
    }
}

public interface IProfileDao
{
    string GetPassword(string account);

    string GetToken(string account);
}

當然,只有這樣是不夠的,因為 AuthService 一旦與這個 static ProfileDao 耦合,就會拋 NullReferenceException ,因為 _profileDao 並未 assign instance 。

Step 2-internal static setter for injection

沒錯,這一步與之前解決直接耦合簡單工廠的方式幾乎一樣,建立一個 internal property 的 setter 供測試程式注入 stub 物件使用。當沒有被測試程式注入 stub 物件時,代表是 production code runtime 的情境,那麼就要執行跟重構前一模一樣的 production code 。程式碼如下(github 版本):

public static class ProfileDao
{
    private static IProfileDao _profileDao;

    //add internal setter for unit test to inject stub object
    internal static IProfileDao MyProfileDao
    {
        get
        {
            if (_profileDao == null)
            {
                // add a ProfileDaoImpl class and extract the original static function content to this class
                _profileDao = new ProfileDaoImpl();
            }

            return _profileDao;
        }
        set { _profileDao = value; }
    }

    internal static string GetPassword(string account)
    {
        // 轉接到 interface 的方法
        return _profileDao.GetPassword(account);
    }

    internal static string GetToken(string account)
    {
        // 轉接到 interface 的方法
        return _profileDao.GetToken(account);
    }
}

如果 _profileDao 是 null 就回傳一個 ProfileDaoImpl 的 instance 。ProfileDaoImpl 這個 class 實作了 IProfileDao ,而且只需要將原本 static function 的內容原封不動的搬進去即可。如下所示(github 版本):

public class ProfileDaoImpl : IProfileDao
{
    //just simulate data source
    private static Dictionary<string, string> fakePasswordDataset = new Dictionary<string, string>
    {
        {"joey","1234"},
        {"demo","!@#$"},
    };

    public string GetPassword(string account)
    {
        if (!fakePasswordDataset.ContainsKey(account))
        {
            throw new Exception("account not exist");
        }

        return fakePasswordDataset[account];
    }

    public string GetToken(string account)
    {
        //just for demo, it's insecure
        var seed = new Random((account.GetHashCode() + (int)DateTime.Now.Ticks) & 0x0000FFFF);
        var result = seed.Next(0, 999999);
        return result.ToString("000000");
    }

最後,再將 ProfileDao 中 static function 中使用的 _profileDao 改成 property 即可。程式碼如下所示:

public static class ProfileDao
{
    private static IProfileDao _profileDao;

    //add internal setter for unit test to inject stub object
    internal static IProfileDao MyProfileDao
    {
        get
        {
            if (_profileDao == null)
            {
                // add a ProfileDaoImpl class and extract the original static function content to this class
                _profileDao = new ProfileDaoImpl();
            }

            return _profileDao;
        }
        set { _profileDao = value; }
    }

    internal static string GetPassword(string account)
    {
        // replace _profileDao filed to static property
        return MyProfileDao.GetPassword(account);
    }

    internal static string GetToken(string account)
    {
        // replace _profileDao filed to static property
        return MyProfileDao.GetToken(account);
    }
}

Step 3-測試程式注入 stub 物件

既然 static class 有了 interface 的注入接縫,接著只需要在測試程式中,透過 NSub 建立 stub 物件進行注入即可。程式碼如下(github 版本):

[TestClass]
public class AuthServiceTest
{
    [TestMethod]
    public void Test_IsValid_joey_1234666666_Should_Return_True()
    {
        var target = new AuthService();
        var account = "joey";
        var password = "1234666666";

        //add IProfileDao stub
        IProfileDao stubProfileDao = Substitute.For<IProfileDao>();
        stubProfileDao.GetPassword("joey").ReturnsForAnyArgs("1234");
        stubProfileDao.GetToken("joey").ReturnsForAnyArgs("666666");

        //inject to ProfileDao static class
        ProfileDao.MyProfileDao = stubProfileDao;

        var actual = target.IsValid(account, password);

        var expected = true;

        //because of random, it should almost always assert failed
        actual.ShouldBe(expected);
    }
}

定義好 stub 物件的行為後,在執行 target.IsValid() 之前,將 stubProfileDao 注入至 ProfileDao 的 MyProfileDao property ,如此一來便能讓 isolated unit test 通過測試,也因為可以定義 stub 物件的行為,所以即使原本相依的內容是亂數產生器,也可以獨立地對 AuthService 商業邏輯進行驗證。

提醒一下,別忘了使用 static 注入,建議要在每一次的 test 開始或結束,還原這個注入的設定,否則這次 test case 的注入,可能導致其他 test case 被連帶影響。例如其他測試案例預期是走 ProfileImpl 的路,卻變成是上個 test case 所注入的 stub 物件。加入 [TestInitialize] 的測試程式碼如下(github 版本):

[TestClass]
public class AuthServiceTest
{
    [TestInitialize]
    public void TestInit()
    {
        //reset stub object
        ProfileDao.MyProfileDao = null;
    }

    [TestMethod]
    public void Test_IsValid_joey_1234666666_Should_Return_True()
    {
        var target = new AuthService();
        var account = "joey";
        var password = "1234666666";

        //add IProfileDao stub
        IProfileDao stubProfileDao = Substitute.For<IProfileDao>();
        stubProfileDao.GetPassword("joey").ReturnsForAnyArgs("1234");
        stubProfileDao.GetToken("joey").ReturnsForAnyArgs("666666");

        //inject to ProfileDao static class
        ProfileDao.MyProfileDao = stubProfileDao;

        var actual = target.IsValid(account, password);

        var expected = true;

        //because of random, it should almost always assert failed
        actual.ShouldBe(expected);
    }
}

Step 4-與 static function 解耦

有了前面步驟的重構,要將 AuthService 與 ProfileDao 的 static function 解耦,也就不是什麼難事了。

只需要將原本在 static class 中墊的 IProfileDao 接縫,改放到 AuthService 中即可。程式碼如下(github 版本):

public class AuthService
{
    private IProfileDao _profileDao;

    internal IProfileDao MyProfileDao
    {
        get
        {
            if (_profileDao == null)
            {
                _profileDao = new ProfileDaoImpl();
            }

            return _profileDao;
        }
        set { _profileDao = value; }
    }

    public bool IsValid(string account, string password)
    {
        //string passwordFromDao = ProfileDao.GetPassword(account);
        //string token = ProfileDao.GetToken(account);
        string passwordFromDao = this.MyProfileDao.GetPassword(account);
        string token = this.MyProfileDao.GetToken(account);

        var validPassword = passwordFromDao + token;

        var isValid = validPassword == password;
        return isValid;
    }
}

可以看到只需要把直接相依於 ProfileDao 的 static function 直接無痛地轉接到 IProfileDao 的 property 上,完全沒有違合感、沒有風險、沒有痛苦。

當然測試程式也要稍微修改一下,因為現在 IProfileDao 的接縫已經改到 AuthService 上了,而且這是 instance property 而不是 static property 上。

    [TestMethod]
    public void Test_IsValid_joey_1234666666_Should_Return_True()
    {
        var target = new AuthService();
        var account = "joey";
        var password = "1234666666";

        //add IProfileDao stub
        IProfileDao stubProfileDao = Substitute.For<IProfileDao>();
        stubProfileDao.GetPassword("joey").ReturnsForAnyArgs("1234");
        stubProfileDao.GetToken("joey").ReturnsForAnyArgs("666666");

        ////inject to ProfileDao static class
        //ProfileDao.MyProfileDao = stubProfileDao;
        target.MyProfileDao = stubProfileDao;

        var actual = target.IsValid(account, password);

        var expected = true;

        //because of random, it should almost always assert failed
        actual.ShouldBe(expected);
    }

到這邊,原本的目標 AuthService 在重構過程中,既可以進行 isolated unit test 以外,最後還將 AuthService 與 ProfileDao static function 進行解耦。若所有 context 與 ProfileDao static function 都解耦完畢,這時 context 應該都只相依於 IProfileDao 了,就勇敢地把 ProfileDao static class 從 production code 中移除吧。

結論

總結一下這個金蟬脫殼手法的好處:

  • 成本低:只需要墊一層 interface 長得跟 static class 樣子一模一樣。只需要把原本 static function 內容,搬到實作 interface 的 class 裡面,完全不需要修改其他東西。
  • 風險低:因為方法簽章完全沒有改變,實際的程式碼邏輯也完全沒有改變,當沒有被外部注入 instance 時,就會執行原本的程式碼。
  • 可測試性:因為有 interface 的注入接縫,所以可以在測試程式中使用 stub 物件來對測試目標進行 isolated unit test 。
  • 擴充性:因為已經將測試目標從直接耦合 static function 改成依賴注入(DI)的方式,context 只相依於抽象,所以實作細節也可以搭配 strategy pattern 來進行抽換。

除了上述的優點以外,還有一個優點是實務上很重要的,也就是持續重構的可行性

通常 static function 的耦合,一定是滿山遍野,不會只有單一 context 或只用單一方法。這個技巧並不要求所有的 context 都要修改到一步到位,解除與 static 的耦合,而是能讓原本與 static function 耦合的內容,以及 context 已經解耦完畢改為相依 ProfileDaoImpl 的情況,兩者所使用到的 production code 仍是同一份。

即使同時並行,當需要修改到原本的實作細節時,也只需要修改 ProfileDaoImpl 的內容即可。而當 context 全部解耦時,該 static class/function 自然也就可以安心地移除了。

不需要再為了 legacy code 上那一堆 static 煩惱了,趕緊動手試試看這個重構技巧吧。

Reference

Github Commit History

分享:
按讚!加入 CodeData Facebook 粉絲群

相關文章

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

熱門論壇文章

熱門技術文章