文章導航-readme
持續集成之單元測試篇——WWH(講講我們做單元測試的故事)
前言
- 臨近上線的幾天內非重大bug不敢進行發版修復,擔心引起其它問題(摁下葫蘆浮起瓢)
- 盡管我們如此小心,仍不能避免修改一些bug而引起更多的bug的現象
- 往往有些bug已經測試通過了但是又復現了
- 我們明明沒有改動過的功能,卻出了問題
- 有些很明顯的bug往往在測試后期甚至到了線上才發現,而此時修復的代價極其之大。
- 測試時間與周期太長並且質量得不到保障
- 項目與服務越來越多,測試人員嚴重不足(后來甚至一個研發兩個測試人員比)
- 上線的時候僅僅一輪回歸測試就需要幾個小時甚至更久
- 無休止的加班上線。。。
如果你對以上問題非常熟悉,那么我想你的團隊和我們遇到了相同的問題。
WWH:Why,What,How為什么要做單元測試,什么事單元測試,如何做單元測試。
一、為什么我們要做單元測試
1.1 問題滋生解決方案——自動化測試
一門技術或一個解決方案的誕生的誕生,不可能憑空去創造,往往是問題而催生出來的。在我的.NET持續集成與自動化部署之路第一篇(半天搭建你的Jenkins持續集成與自動化部署系統)這篇文章中提到,我在做研發負責人的時候飽受深夜加班上線之苦,其中提到的兩個大問題一個是部署問題,另一個就是測試問題。部署問題,我們引入了自動化的部署(后來我們做到了幾分鍾就可以上線)。我們要做持續集成,剩下的就是測試問題了。
回歸測試成了我們的第一大問題。隨着我們項目的規模與復雜度的提升,我們的回歸測試變得越來越困難。由於我們的當時的測試全依賴手工測試,我們項目的迭代周期大概在一個月左右,而測試的時間就要花費一半多的時間。甚至版本上線以后做一遍回歸測試就需要幾個小時的時間。而且這種手工進行的功能性測試很容易有遺漏的地方,因此線上Bug層出不窮。一堆問題困擾着我們,我們不得不考慮進行自動化的測試。
自動化測試同樣不是銀彈,自動化測試雖然與手工測試相比有其優點,其測試效率高,資源利用率高(一般白天開發寫用例,晚上自動化程序跑),可以進行壓力、負載、並發、重復等人力不可完成的測試任務,執行效率較快,執行可靠性較高,測試腳本可重復利用,bug及時發現.......但也有其不可避免的缺點,如:只適合回歸測試,開發中的功能或者變更頻繁的功能,由於變更頻繁而不斷更改測試腳本是不划算的,並且腳本的開發也需要高水平的測試人員和時間......總體來說,雖然自動化的測試可以解決一部分的問題,但也同樣會帶來另一些問題。到底應該不應該引入自動化的測試還需要結合自己公司的團隊現狀來綜合考慮。
而我們的團隊從短期來看引入自動化的測試其必然會帶來一些問題,但長遠來看其優點還是要大於其缺陷的,因此我們決定做自動化的測試,當然這具體是不是另一個火坑還需要時間來判定!
1.2 認識自動化測試金字塔
以上便是經典的自動化測試金字塔。
位於金字塔頂端的是探索性測試,探索性測試並沒有具體的測試方法,通常是團隊成員基於對系統的理解,以及基於現有測試無法覆蓋的部分,做出系統性的驗證,譬如:跨瀏覽器的測試,一些視覺效果的測試等。探索性測試由於這類功能變更比較頻繁,而且全部實現自動化成本較高,因此小范圍的自動化的測試還是有效的。而且其強調測試人員的主觀能動性,也不太容易通過自動化的測試來實現,更多的是手工來完成。因此其成本最高,難度最大,反饋周期也是最慢的。
而在測試金字塔的底部是單元測試,單元測試是針對程序單元的檢測,通常單元測試都能通過自動化的方式運行,單元測試的實現成本較低,運行效率較高,能夠通過工具或腳本完全自動化的運行,此外,單元測試的反饋周期也是最快的,當單元測試失敗后,能夠很快發現,並且能夠較容易的找到出錯的地方並修正。重要的事單元測試一般由開發人員編寫完成。(這一點很重要,因為在我這個二線小城市里,能夠編寫代碼的測試人員實在是罕見!)
在金字塔的中間部分,自底向上還包括接口(契約)測試,集成測試,組件測試以及端到端測試等,這些測試側重點不同,所使用的技術方法工具等也不相同。
總體而言,在測試金字塔中,從底部到頂部業務價值的比重逐漸增加,即越頂部的測試其業務價值越大,但其成本也越來越大,而越底部的測試其業務價值雖小,但其成本較低,反饋周期較短,效率也更高。
1.3 從單元測試開始
我們要開始做自動化測試,但不可能一下子全都做(考慮我們的人力與技能也做不到)。因此必須有側重點,考慮良久最終我們決定從單元測試開始。於是我在剛吃了自動化部署的螃蟹之后,不得不來吃自動化測試的第一個螃蟹。既然決定要做,那么我們就要先明白單元測試是什么?
二、單元測試是什么
2.1 什么是單元測試。
我們先來看幾個常見的對單元測試的定義。
用最簡單的話說:單元測試就是針對一個工作單元設計的測試,這里的“工作單元”是指對一個工作方法的要求。
單元測試是開發者編寫的一小段代碼,用於檢測被測代碼的一個很小的、很明確的功能是否正確。通常而言,一個單元測試用於判斷某個特定條件(或場景)下某個特定函數的行為。
例:
你可能把一個很大的值放入一個有序list中去,然后確認該值出現在list的尾部。或者,你可能會從字符串中刪除匹配某種模式的字符,然后確認字符串確實不再包含這些字符了。
執行單元測試,就是為了證明某段代碼的行為和開發者所期望的一致!
2.2 什么不是單元測試
這里我們暫且先將其分為三種情況
2.2.1 跨邊界的測試
單元測試背后的思想是,僅測試這個方法中的內容,測試失敗時不希望必須穿過基層代碼、數據庫表或者第三方產品的文檔去尋找可能的答案!
當測試開始滲透到其他類、服務或系統時,此時測試便跨越了邊界,失敗時會很難找到缺陷的代碼。
測試跨邊界時還會產生另一個問題,當邊界是一個共享資源時,如數據庫。與團隊的其他開發人員共享資源時,可能會污染他們的測試結果!
2.2.2 不具有針對性的測試
如果發現所編寫的測試對一件以上的事情進行了測試,就可能違反了“單一職責原則”。從單元測試的角度來看,這意味着這些測試是難以理解的非針對性測試。隨着時間的推移,向類或方法種添加了更多的不恰當的功能后,這些測試可能會變的非常脆弱。診斷問題也將變得極具有挑戰性。
如:StringUtility中計算一個特定字符在字符串中出現的次數,它沒有說明這個字符在字符串中處於什么位置也沒有說明除了這個字符出現多少次之外的其他任何信息,那么這些功能就應該由StringUtility類的其它方法提供!同樣,StringUtility類也不應該處理數字、日期或復雜數據類型的功能!
2.2.3 不可預測的測試
單元測試應當是可預測的。在針對一組給定的輸入參數調用一個類的方法時,其結果應當總是一致的。有時,這一原則可能看起來很難遵守。例如:正在編寫一個日用品交易程序,黃金的價格可能上午九時是一個值,14時就會變成另一個值。
好的設計原則就是將不可預測的數據的功能抽象到一個可以在單元測試中模擬(Mock)的類或方法中(關於Mock請往下看)。
三、如何去做單元測試
3.1 單元測試框架
在單元測試框架出現之前,開發人員在創建可執行測試時飽受折磨。最初的做法是在應用程序中創建一個窗口,配有"測試控制工具(harness)"。它只是一個窗口,每個測試對應一個按鈕。這些測試的結果要么是一個消息框,要么是直接在窗體本身給出某種顯示結果。由於每個測試都需要一個按鈕,所以這些窗口很快就會變得擁擠、不可管理。
由於人們編寫的大多數單元測試都有非常簡單的模式:
-
執行一些簡單的操作以建立測試。
-
執行測試。
-
驗證結果。
-
必要時重設環境。
於是,單元測試框架應運而生(實際上就像我們的代碼優化中提取公共方法形成組件)。
單元測試框架(如NUnit)希望能夠提供這些功能。單元測試框架提供了一種統一的編程模型,可以將測試定義為一些簡單的類,這些類中的方法可以調用希望測試的應用程序代碼。開發人員不需要編寫自己的測試控制工具;單元測試框架提供了測試運行程序(runner),只需要單擊按鈕就可以執行所有測試。利用單元測試框架,可以很輕松地插入、設置和分解有關測試的功能。測試失敗時,測試運行程序可以提供有關失敗的信息,包含任何可供利用的異常信息和堆棧跟蹤。
.Net平台常用的單元測試框架有:MSTesting、Nunit、Xunit等。
3.2 簡單示例(基於Nunit)
/// <summary>
/// 計算器類
/// </summary>
public class Calculator
{
/// <summary>
/// 加法
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public double Add(double a, double b)
{
return a + b;
}
/// <summary>
/// 減法
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public double Sub(double a, double b)
{
return a - b;
}
/// <summary>
/// 乘法
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public double Mutiply(double a, double b)
{
return a * b;
}
/// <summary>
/// 除法
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public double Divide(double a, double b)
{
return a / b;
}
}
/// <summary>
/// 針對計算加減乘除的簡單的單元測試類
/// </summary>
[TestFixture]
public class CalculatorTest
{
/// <summary>
/// 計算器類對象
/// </summary>
public Calculator Calculator { get; set; }
/// <summary>
/// 參數1
/// </summary>
public double NumA { get; set; }
/// <summary>
/// 參數2
/// </summary>
public double NumB { get; set; }
/// <summary>
/// 初始化
/// </summary>
[SetUp]
public void SetUp()
{
NumA = 10;
NumB = 20;
Calculator = new Calculator();
}
/// <summary>
/// 測試加法
/// </summary>
[Test]
public void TestAdd()
{
double result = Calculator.Add(NumA, NumB);
Assert.AreEqual(result, 30);
}
/// <summary>
/// 測試減法
/// </summary>
[Test]
public void TestSub()
{
double result = Calculator.Sub(NumA, NumB);
Assert.LessOrEqual(result, 0);
}
/// <summary>
/// 測試乘法
/// </summary>
[Test]
public void TestMutiply()
{
double result = Calculator.Mutiply(NumA, NumB);
Assert.GreaterOrEqual(result, 200);
}
/// <summary>
/// 測試除法
/// </summary>
[Test]
public void TestDivide()
{
double result = Calculator.Divide(NumA, NumB);
Assert.IsTrue(0.5 == result);
}
}
3.3 如何做好單元測試
單元測試是非常有魔力的魔法,但是如果使用不恰當亦會浪費大量的時間在維護和調試上從而影響代碼和整個項目。
好的單元測試應該具有以下品質:
• 自動化
• 徹底的
• 可重復的
• 獨立的
• 專業的
3.3.1 測試哪些內容
一般來說有六個值得測試的具體方面,可以把這六個方面統稱為Right-BICEP:
- Right----結果是否正確?
- B----是否所有的邊界條件都是正確的?
- I----能否檢查一下反向關聯?C----能否用其它手段檢查一下反向關聯?
- E----是否可以強制產生錯誤條件?
- P----是否滿足性能條件?
3.3.2 CORRECT邊界條件
代碼中的許多Bug經常出現在邊界條件附近,我們對於邊界條件的測試該如何考慮?
- 一致性----值是否滿足預期的格式
- 有序性----一組值是否滿足預期的排序要求
- 區間性----值是否在一個合理的最大值最小值范圍內
- 引用、耦合性----代碼是否引用了一些不受代碼本身直接控制的外部因素
- 存在性----值是否存在(例如:非Null,非零,存在於某個集合中)
- 基數性----是否恰好具有足夠的值
- 時間性----所有事情是否都按照順序發生的?是否在正確的時間、是否及時
3.3.3 使用Mock對象
單元測試的目標是一次只驗證一個方法或一個類,但是如果這個方法依賴一些其他難以操控的東西,比如網絡、數據庫等。這時我們就要使用mock對象,使得在運行unit test的時候使用的那些難以操控的東西實際上是我們mock的對象,而我們mock的對象則可以按照我們的意願返回一些值用於測試。通俗來講,Mock對象就是真實對象在我們調試期間的測試品。
Mock對象創建的步驟:
-
使用一個接口來描述這個對象。
-
為產品代碼實現這個接口。
-
以測試為目的,在mock對象中實現這個接口。
Mock對象示例:
/// <summary>
///賬戶操作類
/// </summary>
public class AccountService
{
/// <summary>
/// 接口地址
/// </summary>
public string Url { get; set; }
/// <summary>
/// Http請求幫助類
/// </summary>
public IHttpHelper HttpHelper { get; set; }
/// <summary>
/// 構造函數
/// </summary>
/// <param name="httpHelper"></param>
public AccountService(IHttpHelper httpHelper)
{
HttpHelper = httpHelper;
}
#region 支付
/// <summary>
/// 支付
/// </summary>
/// <param name="json">支付報文</param>
/// <param name="tranAmt">金額</param>
/// <returns></returns>
public bool Pay(string json)
{
var result = HttpHelper.Post(json, Url);
if (result == "SUCCESS")//這是我們要測試的業務邏輯
{
return true;
}
return false;
}
#endregion
#region 查詢余額
/// <summary>
/// 查詢余額
/// </summary>
/// <param name="account"></param>
/// <returns></returns>
public decimal? QueryAmt(string account)
{
var url = string.Format("{0}?account={1}", Url, account);
var result = HttpHelper.Get(url);
if (!string.IsNullOrEmpty(result))//這是我們要測試的業務邏輯
{
return decimal.Parse(result);
}
return null;
}
#endregion
}
/// <summary>
/// Http請求接口
/// </summary>
public interface IHttpHelper
{
string Post(string json, string url);
string Get(string url);
}
/// <summary>
/// HttpHelper
/// </summary>
public class HttpHelper:IHttpHelper
{
public string Post(string json, string url)
{
//假設這是真實的Http請求
var result = string.Empty;
return result;
}
public string Get(string url)
{
//假設這是真實的Http請求
var result = string.Empty;
return result;
}
}
/// <summary>
/// Mock的 HttpHelper
/// </summary>
public class MockHttpHelper:IHttpHelper
{
public string Post(string json, string url)
{
//這是Mock的Http請求
var result = "SUCCESS";
return result;
}
public string Get(string url)
{
//這是Mock的Http請求
var result = "0.01";
return result;
}
}
如上,我們的AccountService的業務邏輯依賴於外部對象Http請求的返回值在真實的業務中我們給AccountService注入真實的HttpHelper類,而在單元測試中我們注入自己Mock的HttpHelper,我們可以根據不同的用例來模擬不同的Http請求的返回值來測試我們的AccountService的業務邏輯。
注意:記住,我們要測試的是AccountService的業務邏輯:根據不同http的請求(或傳入不同的參數)而返回不同的結果,一定要弄明白自己要測的是什么!而無關的外部對象內的邏輯我們並不關心,我們只需要讓它給我們返回我們想要的值,來驗證我們的業務邏輯即可
關於Mock對象一般會使用Mock框架,關於Mock框架的使用,我們將在下一篇文章中介紹。.net 平台常用的Mock框架有Moq,PhinoMocks,FakeItEasy等。
3.4 單元測試之代碼覆蓋率
在做單元測試時,代碼覆蓋率常常被拿來作為衡量測試好壞的指標,甚至,用代碼覆蓋率來考核測試任務完成情況,比如,代碼覆蓋率必須達到80%或90%。於是乎,測試人員費盡心思設計案例覆蓋代碼。因此我認為用代碼覆蓋率來衡量是不合適的,我們最根本的目的是為了提高我們回歸測試的效率,項目的質量不是嗎?
結束語
本篇文章主要介紹了單元測試的WWH,分享了我們為什么要做單元測試並簡單介紹了單元測試的概念以及如何去做單元測試。當然,千萬不要天真的以為看了本篇文章就能做好單元測試,如果你的組織開始推進了單元測試,那么在推進的過程中相信仍然會遇到許多問題(就像我們遇到的,依賴外部對象問題,靜態方法如何mock......)。如何更好的去做單元測試任重而道遠。下一篇文章將針對我們具體實施推進單元測試中遇到的一些問題,來討論如何更好的做單元測試。如:如何破除依賴,如何編寫可靠可維護的測試,以及如何面向測試進行程序的設計等。
未完待續,敬請關注......
參考
《單元測試的藝術》
我們做單元測試的經歷