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 |

Java 學習之路


