單元測試系列之1:開發測試的那些事兒

引述:程序測試對保障應用程序 正確性而言,其重要性怎麼樣強調都不爲過。JUnit是必須事先掌握的測試框架,大多數測試框架和測試工具都在此基礎上擴展而來,Spring對測試所提 供的幫助類也是在JUnit的基礎上進行演化的。直接使用JUnit測試基於Spring的應用存在諸多不便,不可避免地需要將大量的精力用於應付測試夾 具準備、測試現場恢復、訪問測試數據操作結果等邊緣性的工作中。Mockito、Unitils、Dbunit等框架的出現,這些問題有了很好的解決方 案,特別是Unitils結合Dbunit對測試DAO層提供了強大的支持,大大提高了編寫測試用例的效率和質量。
   這些文章摘自於我的《Spring 3.x企業應用開發實戰》的第16章,我將通過連載的方式,陸續在此發出。歡迎大家討論。


    一種商品只有通過嚴格檢測才能投放市場,一架飛機只有經過嚴格測試才能上天,同樣的,一款軟件只有對其各項功能進行嚴格測試後才能交付使用。不管一個軟件 多麼複雜,它都是由相互關聯的方法和類組成的,每個方法和類都可能隱藏着Bug。只有防微杜漸,小步前進纔可以保證軟件大廈的穩固性,否則隱藏在類中的 Bug隨時都有可能像打開的潘多拉魔盒一樣讓程序陷於崩潰之中,難以駕馭。
    按照軟件工程思想,軟件測試可以分爲單元測試、集成測試、功能測試、系統測試等。功能測試和系統測試一般來說是測試人員的職責,但單元測試和集成測試則必須由開發人員保證。



爲什麼需要單元測試

   軟件開發的標準過程包括以下幾個階段:『需求分析階段』→『設計階段』→『實現階段』→『測試階段』→『發佈』。其中測試階段通過人工或者自動手段來運行 或測試某個系統的過程,其目的在於檢驗它是否滿足規定的需求或弄清預期結果與實際結果之間的差別。測試過程按4個步驟進行,即單元測試、集成測試、系統測 試及發版測試。其中功能測試主要檢查已實現的軟件是否滿足了需求規格說明中確定了的各種需求,以及軟件功能是否完全、正確。系統測試主要對已經過確認的軟 件納入實際運行環境中,與其他系統成份組合在一起進行測試。單元測試、集成測試由開發人員進行,是我們關注的重點,下文對兩者進行詳細說明。

單元測試

   單元測試是開發者編寫的一小段代碼,用於檢驗目標代碼的一個很小的、很明確的功能是否正確。通常而言,一個單元測試用於判斷某個特定條件或特定場景下某個 特定函數的行爲。例如,用戶可能把一個很大的值放入一個有序List中,然後確認該值出現在List 的尾部。或者,用戶可能會從字符串中刪除匹配某種模式的字符,然後確認字符串確實不再包含這些字符了。
   單元測試是由程序員自己來完成,最終受益的也是程序員自己。可以這麼說,程序員有責任編寫功能代碼,同時也就有責任爲自己的代碼編寫單元測試。執行單元測試,就是爲了證明這段代碼的行爲和我們期望的一致。
   在一般情況下,一個功能模塊往往會調用其他功能模塊完成某項功能,如業務層的業務類可能會調用多個DAO完成某項業務。對某個功能模塊進行單元測試時,我 們希望屏蔽對外在功能模塊的依賴,以便將焦點放在目標功能模塊的測試上。這時模擬對象將是最有力的工具,它根據外在模塊的接口模擬特定操作行爲,這樣單元 測試就可以在假設關聯模塊正確工作的情況下驗證本模塊邏輯的正確性了。


集成測試

  單元測試和開發工作是並駕齊驅的工作,甚至是前置性的工作。除了一些顯而易見的功能外,大部分功能(類的方法)都必須進行單元測試,通過單元測試可以保障 功能模塊的正確性。而集成測試則是在功能模塊開發完成後,爲驗證功能模塊之間匹配調用的正確性而進行的測試。在單元測試時,往往需要通過模擬對象屏蔽外在 模塊的依賴,而集成測試恰恰是要驗證模塊之間集成後的正確性。
   舉個例子,當對UserService這個業務層的類進行單元測試時,可以通過創建UserDao、LoginLogDao模擬對象,在假設DAO類正確 工作的情況下對UserService進行測試。而對UserService進行集成測試時,則應該注入真實的UserDao和LoginLogDao進 行測試。
   所以一般來講,集成測試面向的層面要更高一些,一般對業務層和Web層進行集成測試,單元測試則面向一些功能單一的類(如字符串格式化工具類、數據計算 類)。當然,我們可能對某一個類既進行單元測試又進行集成測試,如UserService在模塊開發期間進行單元測試,而在關聯的DAO類開發完成後,再 進行集成測試。

測試好處

    在編寫代碼的過程中,一定會反覆調試保證它能夠編譯通過。但代碼通過編譯,只是說明了它的語法正確。無法保證它的語義也一定正確,沒有任何人可以輕易承諾 這段代碼的行爲一定是正確的。幸運的是,單元測試會爲我們的承諾做保證。編寫單元測試就是用來驗證這段代碼的行爲是否與我們期望的一致。有了單元測試,我 們可以自信地交付自己的代碼,減少後顧之憂。總之進行單元測試,會帶來以下好處:

  • 軟件質量最簡單、最有效的保證;
  • 是目標代碼最清晰、最有效的文檔;
  • 可以優化目標代碼的設計;
  • 是代碼重構的保障;
  • 是迴歸測試和持續集成的基石。



單元測試之誤解

   認爲單元測試影響開發進度,一是藉口,拒絕對單元測試相關知識進行學習(單元測試,代碼重構,版本管理是開發人員的必備);二是單元測試是“先苦後甜”, 剛開始搭建環境,引入額外工作,看似“影響進度”,但長遠來看,由於程序質量提升、代碼返工減少、後期維護工作量縮小、項目風險降低,從而在整體上贏了回 來。

誤解1:影響開發進度

   一旦編碼完成,開發人員總是會迫切希望進行軟件的集成工作,這樣他們就能夠看到系統實際運行效果。這在外表上看來好像加快進度,而像單元測試這樣的活動被看作是影響進度原因之一,推遲了對整個系統進行集成測試的時間。
在實踐中,這種開發步驟常常會導致這樣的結果:軟件甚至無法運行。更進一步的結果是大量的時間將被花費在跟蹤那些包含在獨立單元裏的簡單Bug上 面,在個別情況下,這些Bug也許是瑣碎和微不足道的,但是總的來說,它們會導致推遲軟件產品交付的時間,而且也無法確保它能夠可靠運行。
   在實際工作中,進行了完整計劃的單元測試和編寫實際的代碼所花費的精力大致上是相同的。一旦完成了這些單元測試工作,很多Bug將被糾正,開發人員能夠進行更高效的系統集成工作。這纔是真實意義上的進步,所以說完整計劃下的單元測試是對時間的更高效利用。

誤解2:增加開發成本

   如果不重視程序中那些未被發現的Bug可能帶來的後果。這種後果的嚴重程度可以從一個Bug引起的用戶使用不便到系統崩潰。這種後果可能常常會被軟件的開 發人員所忽視,這種情況會長期損害軟件開發商的聲譽,並且會對未來的市場產生負面影響。相反地,一個可靠的軟件系統的良好的聲譽將有助於一個軟件開發商獲 取未來的市場。
很多研究成果表明,無論什麼時候作出修改都要進行完整的迴歸測試,在生命週期中儘早地對軟件產品進行測試將使效率和質量得到最好的保證。Bug發 現得越晚,修改它所需的費用就越高,因此從經濟角度來看,應該儘可能早地查找和修改Bug。而單元測試就是一個在早期抓住Bug的機會。
   相比後階段的測試,單元測試的創建更簡單,且維護更容易,同時可以更方便地進行重構。從全程的費用來考慮,相比起那些複雜且曠日持久的集成測試,或是不穩定的軟件系統來說,單元測試所需的費用是很低的。

誤解3:我是個編程高手,無須進行單元測試

   在每個開發團隊中都至少有一個這樣的開發人員,他非常擅長於編程,他開發的軟件總是在第一時間就可以正常運行,因此不需要進行測試。你是否經常聽到這樣的 藉口?在現實世界裏,每個人都會犯錯誤。即使某個開發人員可以抱着這種態度在很少的一些簡單程序中應付過去,但真正的軟件系統是非常複雜的。真正的軟件系 統不可以寄希望於沒有進行廣泛的測試和Bug修改過程就可以正常工作。編碼不是一個可以一次性通過的過程。在現實世界中,軟件產品必須進行維護以對操作需 求的改變作出及時響應,並且要對最初的開發工作遺留下來的Bug進行修改。你希望依靠那些原始作者進行修改嗎?這些製造出未經測試的代碼的資深工程師們還 會繼續在其他地方製造這樣的代碼。在開發人員做出修改後進行可重複的單元測試,可以避免產生那些令人不快的負作用。

誤解4:測試人員會測出所有Bug

   一旦軟件可以運行了,開發人員又要面對這樣的問題:在考慮軟件全局複雜性的前提下對每個單元進行全面的測試。這是一件非常困難的事情,甚至在創造一種單元 調用的測試條件時,要全面考慮單元被調用時的各種入口參數。在軟件集成階段,對單元功能全面測試的複雜程度遠遠超過獨立進行的單元測試過程。
   最後的結果是測試將無法達到它所應該有的全面性。一些缺陷將被遺漏,並且很多Bug將被忽略過去。讓我們類比一下,假設我們要清理一臺電腦主機中的灰塵, 如果沒有把主機中各個部件(顯卡、內存等)拆開,無論你用什麼工具,一些灰塵還會隱藏在主機的某些角落無法清理。但我們換個角度想想,如果把主機每個部件 一一拆開,這些死角中的灰塵就容易被發現和接觸到了,並且每一部件的灰塵都可以毫不費力地進行清理。

單元測試之癥結

   測試在軟件開發過程中一直都是備受關注的,測試不僅僅侷限於軟件開發中的一個階段,它已經開始貫穿於整個軟件開發過程。大家普遍認識到,如果測試能在開發 階段進行有效執行,程序的Bug就會被及早發現,其質量就能得到有效的保證,從而減少軟件開發總成本。但是,相對於測試這個詞的流行程度而言,大家對單元 測試的認知普遍存在一些偏差,特別是一些程序員很容易陷入一些誤區,導致了測試並沒有在他們所在的開發項目中起到有效的作用。下面對一些比較具有代表性的 癥結進行剖析,並對於測試背後所蘊含的一些設計思考進行闡述,希望能夠起到拋磚引玉的作用。

癥結1:使用System.out.print跟蹤和運行程序就夠了

   這個癥結可以說是程序員的一種通病,認爲使用System.out.print就可以確保編寫代碼的正確性,無須編寫測試用例,他們覺得編寫用例是在“浪 費時間”。使用System.out.print輸出結果,以肉眼觀察這種刀耕火種的方式進行測試,不僅效率低下,而且容易出錯。

癥結2:使用System.out.print跟蹤和運行程序就夠了

   在編碼的時候,確實存在一些看起來比較難測試的代碼,但是並非無法測試。並且在大多數情況下,還是由於被測試的代碼在設計時沒有考慮到可測試性的問題。編寫程序不僅與第三方一些框架耦合過緊,而且過於依賴其運行環境,從而表現出被測試的代碼本身很難測試。

癥結3:測試代碼可以隨意寫

   編寫測試代碼時抱着一種隨意的態度,沒有弄清測試的真正意圖。編寫測試代碼只是爲了應付任務而已,先編寫程序實現代碼,然後纔去編寫一些單元測試。表現出來的結果是測試過於簡單,只走形式和花架,將大量Bug傳遞給系統測試人員。

癥結4:不關心測試環境

  手工搭建測試環境,測試數據,造成維護困難,佔據了大量時間,嚴重影響效率。對測試產 生的“垃圾”不清除,不處理。造成測試不能重複進行,導致脆弱的測試,需要維護好測試環境,做一個“低碳環保”的測試者。

癥結5:測試環境依賴性大

   測試環境依賴性大,沒有有效隔離測試目標及其依賴環境,一是使測試不聚焦;二是常因依賴環境的影響造成失敗;三是因依賴環境太厚重從而降低測試的效率(如依賴數據庫或依賴網絡資源,如郵件系統、Web服務)。

單元測試基本概念

被測系統:SUT(System Under Test)

   被測系統(System under test,SUT)表示正在被測試的系統,目的是測試系統能否正確操作。這一詞語常用於軟件測試中。軟件系統測試的一個特例是對應用軟件的測試,稱爲被測應用程序(application under test,AUT)。
   SUT也表明軟件已經到了成熟期,因爲系統測試在測試周期中是集成測試的後一階段。

測試替身:Test Double

   在單元測試時,使用Test Double減少對被測對象的依賴,使得測試更加單一。同時,讓測試案例執行的時間更短,運行更加穩定,同時能對SUT內部的輸入輸出進行驗證,讓測試更 加徹底深入。但是,Test Double也不是萬能的,Test Double不能被過度使用,因爲實際交付的產品是使用實際對象的,過度使用Test Double會讓測試變得越來越脫離實際。
要理解測試替身,需要了解一下Dummy Objects、Test Stub、Test Spy、Fake Object
   這幾個概念,下面我們對這些概念分別進行說明。

Dummy Objects

    Dummy Objects泛指在測試中必須傳入的對象,而傳入的這些對象實際上並不會產生任何作用,僅僅是爲了能夠調用被測對象而必須傳入的一個東西。

Test Stub

    測試樁是用來接受SUT內部的間接輸入(indirect inputs),並返回特定的值給SUT。可以理解Test Stub是在SUT內部打的一個樁,可以按照我們的要求返回特定的內容給SUT,Test Stub的交互完全在SUT內部,因此,它不會返回內容給測試案例,也不會對SUT內部的輸入進行驗證。

Test Spy

   Test Spy像一個間諜,安插在了SUT內部,專門負責將SUT內部的間接輸出(indirect outputs)傳到外部。它的特點是將內部的間接輸出返回給測試案例,由測試案例進行驗證,Test Spy只負責獲取內部情報,並把情報發出去,不負責驗證情報的正確性。

Mock Object

   Mock Object和Test Spy有類似的地方,它也是安插在SUT內部,獲取到SUT內部的間接輸出(indirect outputs),不同的是,Mock Object還負責對情報(intelligence)進行驗證,總部(外部的測試案例)信任Mock Object的驗證結果。

Fake Object

   經常,我們會把Fake Object和Test Stub搞混,因爲它們都和外部沒有交互,對內部的輸入輸出也不進行驗證。不同的是,Fake Object並不關注SUT內部的間接輸入(indirect inputs)或間接輸出(indirect outputs),它僅僅是用來替代一個實際的對象,並且擁有幾乎和實際對象一樣的功能,保證SUT能夠正常工作。實際對象過分依賴外部環境,Fake Object可以減少這樣的依賴。

測試夾具:Test Fixture

    所謂測試夾具(Fixture),就是測試運行程序(test runner)會在測試方法之前自動初始化、回收資源的工作。JUnit4之前是通過setUp、TearDown方法完成。在JUnit4中,仍然可以 在每個測試方法運行之前初始化字段和配置環境,當然也是通過註解完成。在JUnit4中,通過@Befroe替代setUp方法;@After替代 tearDown方法。在一個測試類中,甚至可以使用多個@Before來註解多個方法,這些方法都是在每個測試之前運行。說明一點,@Before是在 每個測試方法運行前均初始化一次,同理@Ater是在每個測試方法運行完畢後均執行一次。也就是說,經這兩個註解的初始化和註銷,可以保證各個測試之間的 獨立性而互不干擾,它的缺點是效率低。另外,不需要在超類中顯式調用初始化和清除方法,只要它們不被覆蓋,測試運行程序將根據需要自動調用這些方法。超類 中的@Before方法在子類的@Before方法之前調用(與構造函數調用順序一致),@After方法是子類在超類之前運行。
    一個測試用例可以包含若干個打上@Test註解的測試方法,測試用例測試一個或多個類API接口的正確性,當然在調用類API時,需要事先創建這個類的對象及一些關聯的對象,這組對象就稱爲測試夾具(Fixture),相當於測試用例的“工作對象”。
    前面講過,一個測試用例類可以包含多個打上@Test註解的測試方法,在運行時,每個測試方法都對應一個測試用例類的實例。當然,用戶可以在具體的測試方 法裏聲明並實例化業務類的實例,在測試完成後銷燬它們。但是,這麼一來就要在每個測試方法中都重複這些代碼,因爲TestCase實例依照以下步驟運行。

  •   創建測試用例的實例。
  •   使用註解@Before註解修飾用於初始化夾具的方法。
  •   使用註解@After註解修飾用於註銷夾具的方法。
  •   保證這兩種方法都使用 public void 修飾,而且不能帶有任何參數。



TestCase實例運行過程如下圖所示:



   之所以每個測試方法都需要按以上流程運行,是爲了防止測試方法相互之間的影響,因爲在同一個測試用例類中不同測試方法可能會使用到相同的測試夾具,前一個 測試方法對測試夾具的更改會影響後一個測試方法的現場。而通過如上的運行步驟後,因爲每個測試方法運行前都重建運行環境,所以測試方法相互之間就不會有影 響了。
   可是,這種夾具設置方式還是引來了批評,因爲它效率低下,特別是在設置 Fixture 非常耗時的情況下(例如設置數據庫鏈接)。而且對於不會發生變化的測試環境或者測試數據來說,是不會影響到測試方法的執行結果的,也就沒有必要針對每一個 測試方法重新設置一次夾具。因此在 JUnit 4 中引入了類級別的夾具設置方法,編寫規範說明如下。

  •  
  •   創建測試用例的實例。
  •   使用註解BeforeClass 修飾用於初始化夾具的方法。
  •   使用註解AfterClass 修飾用於註銷夾具的方法。
  •   保證這兩種方法都使用 public static void 修飾,而且不能帶有任何參數。



   類級別的夾具僅會在測試類中所有測試方法執行之前執行初始化,並在全部測試方法測試完畢之後執行註銷方法,如圖16-2所示。

測試用例:Test Case

   有了測試夾具,就可以開始編寫測試用例的測試方法了。當然也可以不需要測試夾具而直接編寫測試用例方法。
   在JUnit 3中,測試方法都必須以test爲前綴,且必須是public void的,JUnit 4之後,就沒有這個限制,只要在每個測試方法標註@Test註解,方法簽名可以是任意取名。



  可以在一個測試用例中添加多個測試方法,運行器爲每個方法生成一個測試用例實例並分別運行。

測試套件:Test Suite

   如果每次只能運行一個測試用例,那麼又陷入了傳統測試(使用main()方法進行測試)的窘境:手工去運行一個個測試用例,這是非常煩瑣和低效的,測試套 件專門爲解決這一問題而來。它通過TestSuite對象將多個測試用例組裝成一個測試套件,則測試套件批量運行。需要特別指出的是,可以把一個測試套件 整個添加到另一個測試套件中,就像小筐裝進大筐裏變成一個筐一樣。
    JUnit4中最顯著的特性是沒有套件(套件機制用於將測試從邏輯上分組並將這這些測試作爲一個單元測試來運行)。爲了替代老版本的套件測試,套件被兩個 新註解代替:@RunWith、@SuteClasses。通過@RunWith指定一個特殊的運行器,即Suite.class套件運行器,並通過 @SuiteClasses註解,將需要進行測試的類列表作爲參數傳入。

   創建步驟說明如下:

  •   創建一個空類作爲測試套件的入口(這個空類必須使用public修飾符,而且存在無參構造函數)。
  •   將@RunWith、@SuiteClasses註釋修飾這個空類。
  •   把Suite.class作爲參數傳入@RunWith註釋,以提示JUnit將此類指定爲運行器。
  •   將需要測試的類組成數組作爲@SuiteClasses的參數。



斷言:Assertions

    斷言(assertion)是測試框架裏面的若干個方法,用來判斷某個語句的結果是否爲真或判斷是否與預期相符。比如assertTrue這一方法就是用來判定一條語句或一個表達式的結果是否爲真,如果條件爲假,那麼該斷言就會執行失敗。
    在JUnit 4中一個測試類並不繼承自TestCase(在JUnit 3.8中,這個類中定義了assertEquals()方法),所以你必須使用前綴語法(舉例來說,Assert.assertEquals())或者靜 態地導入Assert類。這樣我們就可以完全像以前一樣使用assertEquals方法。
    由於JDK 5.0自動裝箱機制的出現,原先的12個assertEquals方法全部去掉了。例如,原先JUnit 3.8中的assertEquals(long,long)方法在JUnit 4中要使用assertEquals(Object,Object),對於assertEquals(byte,byte)、 assertEquals(int,int)等也是這樣。
    在JUnit 4中,新集成了一個assert關鍵字。你可以像使用assertEquals方法一樣來使用它,因爲它們都拋出相同的異常 (java.lang.AssertionError)。JUnit 3.8的assertEquals將拋出一個junit.framework.AssertionFailedError。注意,當使用assert時, 你必須指定Java的"-ea"參數,否則斷言將被忽略。

 

http://stamen.iteye.com/blog/1466145

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章