TDD中的單元測試寫多少才夠?


測試驅動開發(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種可能,我們通過路徑覆蓋的方法設計出來的測試用例:
用例 參數 返回值
Test Case 1 a=5, b=5 0
Test Case 2 a=15, b=5 1
Test Case 3 a=5, b=15 10
Test Case 1 a=15, b=15 11

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);
}
}
 
image

如果僅從代碼覆蓋率的角度來看,單元測試Should_Trim_Username、Should_Save_MD5_Hash_Password不會增加覆蓋率,似乎沒有必要,但是從邏輯上看,創建的賬戶的Username頭尾不能包含空白字符,密碼也不能明文存儲,顯然這兩個用例是非常有必要的。
 
單元測試寫多少才夠?這個問題沒有確定的答案,但原則是讓你自己覺得安全。代碼覆蓋率高不能保證安全,真正的安全需要用測試用例覆蓋邏輯。
 

參考文章:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM