C# Test Legacy Code(4)Unit Test with Static Functions
C# Test Legacy Code(3)Compare Object Equality << 前情 前言前面幾篇文章介紹了常見棘手的單元測試問題,包含了: 這篇文章所要解決的問題類型,也是 legacy code 常見的壞味道,直接與 static function 耦合。這與上面的第二篇直接相依於簡單工廠的 static function 有一點像,但目的卻是不同。 一樣透過簡單且不帶太多風險的手法(我喜歡稱它為金蟬脫殼),來解決下面的問題:
使用 Static 的迷思過去很常聽到開發人員一些錯誤的迷思,導致出現一堆直接與 static 耦合的理由,例如:
直接相依 static function 的主要問題是:
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 中移除吧。 結論總結一下這個金蟬脫殼手法的好處:
除了上述的優點以外,還有一個優點是實務上很重要的,也就是持續重構的可行性。 通常 static function 的耦合,一定是滿山遍野,不會只有單一 context 或只用單一方法。這個技巧並不要求所有的 context 都要修改到一步到位,解除與 static 的耦合,而是能讓原本與 static function 耦合的內容,以及 context 已經解耦完畢改為相依 ProfileDaoImpl 的情況,兩者所使用到的 production code 仍是同一份。 即使同時並行,當需要修改到原本的實作細節時,也只需要修改 ProfileDaoImpl 的內容即可。而當 context 全部解耦時,該 static class/function 自然也就可以安心地移除了。 不需要再為了 legacy code 上那一堆 static 煩惱了,趕緊動手試試看這個重構技巧吧。 Reference |