TDD中的單元測試寫多少才夠?
3月15日,我參加了thoughtworks組織的“結對編程和TDD Openworkshop”活動,聆聽了tw的資深咨詢專家仝(tong2)鍵的精彩講解,并在講師的帶領下實際參與了一次TDD和結對編程的過程。活動中,仝鍵老師對到底寫多少測試用例才夠的問題,給出了下面一個解釋:
我們寫單元測試,有一個重要的原因是用來防止自己犯低級錯誤的。我們不能把寫實現代碼的人當作我們的敵人,一定要把全部情況都測到,以防止他們在里面故意留下各種隱蔽的陷阱。測試寫的再多可能也沒有辦法覆蓋全部情況,所以只要能讓自己感到安全即可。怎樣才能讓自己感到安全呢?這是沒有標準答案的,只能是寫多了測試以后慢慢體會。
另外,寫測試也要花時間的,比如compare這個方法的實現部分,我們只花了一兩分鐘就寫完了,而這些測試代碼,我們花了足足半個多小時,這樣做值得嗎?對于簡單的業務邏輯來說,當然是不值得的,畢竟我們還很多工作等著做,老板花錢是為了我們的產品代碼,而不是測試代碼。
再考慮一種情況,我要創業,想了一個點子,做了一個網站,我當然是想以最快的速度把它做成型讓別人用。如果我在完全不知道人們會不會喜歡的時候,先花大量時間寫測試,最后發現沒人用只能丟掉,這些測試豈不是白寫了。
所以還是上面那句話:單元測試是讓你提升自己對代碼的信心的,只要你感覺安全可以繼續開發時就夠了,不是越多越好。
我相信上面一段解釋對于本文中提出的問題大家都沒有什么異議。但是這里我們不考慮特殊情況,在實際操作中,是否有辦法對單元測試這一工作進行衡量?來判斷是否足夠?
使用代碼覆蓋率來衡量單元測試是否足夠
常見的代碼覆蓋率有下面幾種:
語句覆蓋(Statement Coverage):這是最常用也是最常見的一種覆蓋方式,就是度量被測代碼中每個可執行語句是否被執行到了。
判定覆蓋(Desicion Coverage):它度量程序中每一個判定的分支是否都被測試到了。
條件覆蓋(Condition Coverage):它度量判定中的每個子表達式結果true和false是否被測試到了。
路徑覆蓋(Path Coverage):它度量了是否函數的每一個分支都被執行了。
前三種覆蓋率大家可以查看下面的引用的第3篇文章,這里就不再多說。我們通過一個例子,來看看路徑覆蓋。比如下面的測試代碼中有兩個判定分支
int foo(int a, int b) { int nReturn = 0; if (a < 10) {// 分支一 nReturn+= 1; } if (b < 10) {// 分支二 nReturn+= 10; } return nReturn; } |
我們仔細看看邏輯,nReturn的結果一共有4種可能,我們通過路徑覆蓋的方法設計出來的測試用例:
Perfect。但是實際中的代碼往往比上面的例子復雜,如果代碼中有5個if-else,那么按照路徑覆蓋的方法,至少需要25=32個測試用例。這樣簡直要瘋掉了。
沒必要追求代碼覆蓋率,真正要覆蓋的是邏輯
簡單追求代碼結構上的覆蓋率,容易導致產生大量無意義的測試用例或者無法覆蓋關鍵業務邏輯。我們再看看上面解釋的第一段話。
我們寫單元測試,有一個重要的原因是用來防止自己犯低級錯誤的。我們不能把寫實現代碼的人當作我們的敵人,一定要把全部情況都測到,以防止他們在里面故意留下各種隱蔽的陷阱。測試寫的再多可能也沒有辦法覆蓋全部情況,所以只要能讓自己感到安全即可。怎樣才能讓自己感到安全呢?這是沒有標準答案的,只能是寫多了測試以后慢慢體會。
怎么才算讓自己感到安全?覆蓋邏輯,而不是代碼。站在使用者的角度考慮,需要關心的是軟件實現邏輯,而不是覆蓋率。如下面的例子:
public class UserBusiness { public string CreateUser(User user) { string result = "success"; if (string.IsNullOrEmpty(user.Username)) { result = "usename is null or empty"; } else if (string.IsNullOrEmpty(user.Password)) { result = "password is null or empty"; } else if (user.Password != user.ConfirmPassword) { result = "password is not equal to confirmPassword"; } else if (string.IsNullOrEmpty(user.Creator)) { result = "creator is null or empty"; } else if (user.CreateDate == new DateTime()) { result = "createdate must be assigned value"; } else if (string.IsNullOrEmpty(user.CreatorIP)) { result = "creatorIP is null or empty"; } if (result != "success") { return result; } user.Username = user.Username.Trim(); user.Password = BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(user.Password))); UserDataAccess dataAccess = new UserDataAccess(); dataAccess.CreateUser(user); return result; } } 在寫UserBusiness.CreateUser的測試用例的時候,我們定義了下面幾個單元測試用例: [TestClass()] public class UserBusinessTest { private TestContext testContextInstance; /// <summary> ///Gets or sets the test context which provides ///information about and functionality for the current test run. ///</summary> public TestContext TestContext { get { return testContextInstance; } set { testContextInstance = value; } } [TestMethod()] public void Should_Username_Not_Null_Or_Empty() { UserBusiness target = new UserBusiness(); User user = new User(); string expected = "usename is null or empty"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } [TestMethod()] public void Should_Password_Not_Null_Or_Empty() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai" }; string expected = "password is null or empty"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } |
[TestMethod()] public void Should_Password_Equal_To_ConfirmPassword() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai", Password = "a121ww123", ConfirmPassword = "a121ww1231" }; string expected = "password is not equal to confirmPassword"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } [TestMethod()] public void Should_Creator_Not_Null_Or_Empty() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai", Password = "a121ww123", ConfirmPassword = "a121ww1231" }; string expected = "password is not equal to confirmPassword"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } [TestMethod()] public void Should_CreateDate_Assigned_Value() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai", Password = "a121ww123", ConfirmPassword = "a121ww123", Creator = "ethan.cai" }; string expected = "createdate must be assigned value"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } [TestMethod()] public void Should_CreatorIP_Not_Null_Or_Empty() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai", Password = "a121ww123", ConfirmPassword = "a121ww123", Creator = "ethan.cai", CreateDate = DateTime.Now }; string expected = "creatorIP is null or empty"; string actual = target.CreateUser(user); Assert.AreEqual(expected, actual); } [TestMethod()] public void Should_Trim_Username() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai ", Password = "a121ww123", ConfirmPassword = "a121ww123", Creator = "ethan.cai", CreateDate = DateTime.Now, CreatorIP = "127.0.0.1" }; string expected = "ethan.cai"; target.CreateUser(user); Assert.AreEqual(expected, user.Username); } [TestMethod()] public void Should_Save_MD5_Hash_Password() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai ", Password = "a121ww123", ConfirmPassword = "a121ww123", Creator = "ethan.cai", CreateDate = DateTime.Now, CreatorIP = "127.0.0.1" }; string actual = target.CreateUser(user); Assert.IsTrue("success" == actual && user.Password == BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes("a121ww123")))); } [TestMethod()] public void Should_Create_User_Successfully_When_User_Is_OK() { UserBusiness target = new UserBusiness(); User user = new User() { Username = "ethan.cai ", Password = "a121ww123", ConfirmPassword = "a121ww123", Creator = "ethan.cai", CreateDate = DateTime.Now, CreatorIP = "127.0.0.1" }; string expected = "success"; string actual = target.CreateUser(user); Assert.IsTrue(expected == actual); } } |
如果僅從代碼覆蓋率的角度來看,單元測試Should_Trim_Username、Should_Save_MD5_Hash_Password不會增加覆蓋率,似乎沒有必要,但是從邏輯上看,創建的賬戶的Username頭尾不能包含空白字符,密碼也不能明文存儲,顯然這兩個用例是非常有必要的。
單元測試寫多少才夠?這個問題沒有確定的答案,但原則是讓你自己覺得安全。代碼覆蓋率高不能保證安全,真正的安全需要用測試用例覆蓋邏輯。
posted on 2014-04-18 13:25 順其自然EVO 閱讀(186) 評論(0) 編輯 收藏 所屬分類: 測試學習專欄