如果這樣說不會(令您)很痛苦的話,請設想您是一名任職於一家 2002 年 早期創建的公司的開發人員。在金錢的驅動下,您和您的團隊接到了一項任務, 即使用最新且最強大的 Java™ API 構建一個大型的數據驅動的 Web 應用 程序。您和公司管理層都堅定不疑地相信這就是最終將被稱為敏捷過程 的東西 。從第一天起,您就用 JUnit 構建測試,且把它作為 Ant 構建過程的一部分盡 可能頻繁地運行。還將設置一個定時任務在夜間運行構建。在接下來的某個時刻 ,有人會下載 CruiseControl,不斷增長的測試套件會在每次簽入時運行。
時至今日
經過過去幾年的努力,您的公司已經開發了一個龐大的 代碼庫和一個同樣龐大的 JUnit 測試套件。一切都很正常,直到大約一年前, 測試套件包含了 2000 個測試,同時人們開始注意到運行構建過程用時超過三個 小時。在此之前的幾個月,由於 CI 服務器資源緊張,您在代碼簽入時通過 Continuous Integration(CI)停止運行單元測試,並將測試切換到夜間運行, 這使得之後的早晨時間非常緊張,於是開發人員努力去弄清楚是什麼出錯以及為 什麼出錯。
這些天,似乎測試套件整晚極少超過一次運行,為什麼會這樣呢?因為它們 費時太多!沒人會僅僅為了弄明白系統是否運行良好而幾個小時守在那裡。此外 ,整個測試套件都是在晚上運行,不是嗎?
由於測試運行得太不頻繁, 它們常常充滿了錯誤。因而,您和您的團隊開始質疑單元測試的價值:如果它們 對代碼質量那麼重要,那又為什麼會讓人這麼頭痛呢?你們的結論是:單元測試 有其重要的作用,但必須要能用一種更為敏捷的方式運行它們。
嘗試測 試分類
您所需要的是一個將構建轉換到一種更為敏捷狀態的策略。您需要這樣一種 解決方案,使一天當中運行測試的次數超過一次,並使測試套件恢復到要用三個 小時才能完成構建之前的水平。
為完整地恢復整個測試套件,在試圖提出一個策略之前,很有必要弄清楚通 用術語 “單元測試” 的含義。諸如 “我家有一個動物” 和 “我喜歡車” 這 樣的表述並不很具體,“我們編寫單元測試” 也是一樣。這年頭,單元測試能 代表一切。
就拿之前有關動物和車的表述來說:它們導致了更多的疑問。例如,您家有 哪種動物?是一只貓、一條蜥蜴還是一頭熊?“我家有一頭熊” 和 “我家有一 只貓” 截然不同。同樣,當和汽車銷售員交談時,只說 “我喜歡車” 沒什麼 用處。您喜歡哪種車:賽車、卡車還是旅行車?任何一個答案都能帶來截然不同 的結果。
同樣,對於開發人員測試來說,按照類型 將測試分類也是很有用的。這樣做 能夠實現更為精確的語言,並且能使您的團隊以不同的頻率運行不同的測試類型 。為了避免運行所有 “單元測試” 所需的令人恐懼的三小時構建時間,分類是 關鍵。
三種類型
測試套件可以形象地分為三層,每一層代表一種不同的開發人員測試類型, 該測試類型由其運行時間的長短決定。正如在圖 1 中看到的那樣,每一層都增 加了總的構建時間,要麼增加了運行時間,要麼最終增加了編寫時間。
圖 1.測試分類的三個層次
底層由運行時間最短的測試構成,可以想象的到,它們也最易於編寫。這些 測試占用的代碼量也是最少的。頂層由更高級別的測試構成,這些測試占用了應 用程序更大的部分。這些測試有一點難於編寫,執行時間也要長得多。中間層是 處於這兩個極端中間的測試類型。
三種類型如下所示:
單元測試
組件測試
系統測試
讓我們分別來看一下。
1. 單元測試
單元測試隔離地 驗證一個或多個對象。單元測試不處理數據庫、文件系統或 任何可能延長測試運行時間的內容;因而,從第一天就可以編寫單元測試。事實 上,這也正是 JUnit 設計的確切目的所在。單元測試的隔離概念有無數的模擬 對象庫作後盾,這些庫便利了將一個特定的對象從其外部依賴項中隔離出來。而 且,單元測試能夠在真正要測試的代碼前編寫 —— 由此有了測試優先開發 的 概念。
單元測試通常易於編寫,因為它們並不依靠於架構的依賴項,且通常運行得 很快。缺點是,獨立的單元測試只能覆蓋稍顯有限的代碼。單元測試的重大價值 在於:它們使開發人員能夠在盡可能低的層面上保證對象的可靠性。
由於單元測試運行得如此之快且如此易於編寫,代碼庫中應包含許多單元測 試,並且應該盡可能多地運行它們。在執行構建時,應該經常 運行它們,不管 是在機器上,還是在 CI 環境的上下文中(這意味著,代碼一經簽入 SCM 環境 ,就要運行單元測試)。
2. 組件測試
組件測試驗證多個相互作用的對象,但它突破了隔離的概念。由於組件測試 處理一個架構的多個層次,所以它們經常用於處理數據庫、文件系統、網絡元素 等。同樣,提前編寫組件測試有點難,所以將其包含至一個真正的測試優先/測 試驅動的場景中是很大的挑戰。
編寫組件測試要花更長的時間,因為它們比單元測試所涉及的東西要多。另 一方面,由於其寬廣的范圍,它們實現了比單元測試更廣的代碼覆蓋率。當然它 們也要花更多時間運行,所以同時運行很多的組件測試會顯著地 增加總的測試 時間。
許多框架有助於測試大型架構組件。DbUnit 是這類框架的一個典型例子。 DbUnit 能夠很好地處理在測試狀態間建立一個數據庫這樣的復雜性,因而它會 使編寫依賴於數據庫的測試變得較為簡單。
當構建的測試延長時,通常都預示著包含了一個大型的組件測試套件。由於 這些測試比真正的單元測試運行時間長,因而不能一直運行它們。相應地,在 CI 環境中這些測試可以至少 每小時運行一次。在簽入任何代碼前,也應該總在 一個本地開發人員機器上運行這些測試。
驗收測試
驗收測試 和功能測試類似,不同之處在於,理想情況下,驗收 測試是由客戶或最終用戶編寫的。正如功能測試一樣,驗收測試也像最終用戶測 試那樣進行。Selenium是一個備受矚目的驗收框架,它使用浏覽器測試 Web 應 用程序。Selenium 在構建過程中可以是自動運行的,就像 JUnit 測試一樣。但 Selenium 是一個新平台:它不使用 JUnit,在使用方式上也不相似。
3. 系統測試
系統測試端到端地 驗證一個軟件應用程序。因而,它們引入了一個更高級別 的架構復雜度:整個應用程序必需為要進行的系統測試而運行。如果是一個 Web 應用程序,您就需要訪問數據庫以及 Web 服務器、容器和任何與運行系統測試 相關的配置。其遵循這樣的原則,即大多數系統測試都在軟件生命周期的較後周 期中編寫。
編寫系統測試是個挑戰,也需要大量的時間來實際地執行。而另一方面,就 架構性代碼覆蓋率來講,系統測試是一件極為劃算的事情。
系統測試和功能測試很相似。所不同的是,它們並不仿效用戶,而是模擬出 一個用戶。與在組件測試中一樣,現在創建了大量的框架來為這些測試提供方便 。例如,jWebUnit 通過模擬一個浏覽器來測試 Web 應用程序。
用 jWebUnit 還是 Selenium 呢?
jWebUnit 是為系統測試設計的一個 JUnit 擴展框架;因而它需要您來編寫測試。Selenium 在驗收測試和功能測試 方面表現卓越,不同於 jWebUnit,它使非程序員也能夠編寫測試。理想情況下 ,團隊可以同時 使用兩種工具來驗證應用程序的功能。
實現測試分類
所以,您的單元測試套件就是名副其實的包括單元測試、組件測試和系統測 試的套件。不僅如此,在檢查了這些測試後,您現在知道構建花了三個小時的原 因是:絕大部分時間都被組件測試所占用。下一個問題是,如何用 JUnit 實現 測試分類?
有幾種方式可選,但這裡我們只關注於其中兩種最簡單的方式:
根據所需種類創建定制的 JUnit 套件文件。
為每種測試類型創建定制目錄。
用 TestNG 進行測試分類
用 TestNG 實現測試分類相當簡單。用 TestNG 的 group 注釋按照種類在邏輯上劃分測試,這與將適當的 group 注釋應用到所 需測試中一樣簡單。這樣一來,運行一個特定類型實際上就是將一個相應的組名 稱傳遞給一個測試運行程序,如 Ant。
創建定制套件
可以使用 JUnit 的 TestSuite 類(屬於 Test 類型)來定義許多互相歸屬 的測試。首先,創建一個 TestSuite 實例,並為其添加相應的測試類或測試方 法。然後,可以通過定義一個叫做 suite() 的 public static 方法,在 TestSuite 實例中指定 JUnit。包含的所有測試隨後將在單個運行中執行。因而 ,可以通過創建單元 TestSuite、組件 TestSuite 和系統 TestSuite 來實現測 試分類。
例如,清單 1 中顯示的類創建了一個 TestSuite,其持有 suite() 方法中 所有的組件測試。請注意此類並不是非常特定於 JUnit 的。它既沒有擴展 TestCase,也沒有定義任何測試用例。但它會反射性地找到 suite() 方法並運 行由它返回的所有測試。
清單 1. 用於組件測試的 TestSuite
package test.org.acme.widget;
import junit.framework.Test;
import junit.framework.TestSuite;
import test.org.acme.widget.*;
public class ComponentTestSuite {
public static void main(String[] args) {
junit.textui.TestRunner.run(ComponentTestSuite.suite());
}
public static Test suite(){
TestSuite suite = new TestSuite();
suite.addTestSuite(DefaultSpringWidgetDAOImplTest.class);
suite.addTestSuite(WidgetDAOImplLoadTest.class);
...
suite.addTestSuite(WidgetReportTest.class);
return suite;
}
}
定義 TestSuite 的過程的確需要浏覽現有的測試,並將它們添加到相應的類 中(即,將所有的單元測試添加到一個 UnitTestSuite 中)。這也意味著,由 於在一個給定分類中編寫新測試,不得不將它們按照一定的程序添加到適當的 TestSuite 中,當然,還需要重新編譯 它們。
運行獨立的 TestSuites,然後試著創建單一的 Ant 任務,Ant 任務調用正 確的測試集。可以定義一個 component-test 任務,用於組織 ComponentTestSuite 等,正如清單 2 中所示:
清單 2. 只運行組件測試的 Ant 任務
<target name="component-test"
if="Junit.present"
depends="junit-present,compile-tests">
<mkdir dir="${testreportdir}"/>
<junit dir="./" failureproperty="test.failure"
printSummary="yes"
fork="true" haltonerror="true">
<sysproperty key="basedir" value="."/>
<formatter type="xml"/>
<formatter usefile="false" type="plain"/>
<classpath>
<path refid="build.classpath"/>
<pathelement path="${testclassesdir}"/>
<pathelement path="${classesdir}"/>
</classpath>
<batchtest todir="${testreportdir}">
<fileset dir="test">
<include name="**/ComponentTestSuite.java"/>
</fileset>
</batchtest>
</junit>
</target>
理想情況下,還需要有調用單元測試和系統測試的任務。最後,在想要運行 整個測試套件時,應該創建一個依賴於所有三種測試種類的第四項任務,如清單 3 中如示:
清單 3. 用於所有測試的 Ant 任務
<target name="test-all" depends="unit-test,component-test,system-test"/>
創建定制 TestSuite 是實現測試分類的一個快速解決方案。這個方法的缺點 是:一旦創建新測試,就必須通過編程將它們添加到適當的 TestSuite 中,這 很痛苦。為每種測試創建定制目錄更具擴展性,且允許不 經過重新編譯就添加 新的經過分類的測試。
創建定制目錄
我發現,用 JUnit 實現測試分類最簡單的方法是將測試在邏輯上劃分為與其 測試類型相應的特定目錄。使用這項技術,所有的單元測試將駐留在一個 unit 目錄中,所有的組件測試將駐留在一個 component 目錄中,依此類推。
例如,在一個保存所有未分類測試的 test 目錄中,可以創建三個新的子目 錄,如清單 4 所示:
清單 4. 實現測試分類的目錄結構
acme-proj/
test/
unit/
component/
system/
conf/
為運行這些測試,必需至少定義四個 Ant 任務:為單元測試定義一個,為組 件測試定義一個,依此類推。第 4 項任務是一個方便的任務,它運行所有三種 測試類型(如 清單 3 所示)。
該 JUnit 任務和 清單 2 中定義的任務非常相似。所不同的是該任務 batchtest 方面的一個細節。此時,fileset 指向一個具體的目錄。在清單 5 的例子中,它指向 unit 目錄。
清單 5. 用於運行所有單元測試的 JUnit 任務的批量測試方面
<batchtest todir="${testreportdir}">
<fileset dir="test/unit">
<include name="**/**Test.java"/>
</fileset>
</batchtest>
請注意,這個測試只運行 test/unit 目錄下的所有測試。當創建了新的單元 測試(或針對此問題的任何其他測試),只需要將它們放到該目錄下,一切就准 備妥當了!比起需要將一行新代碼添加到 TestSuite 文件並進行重新編譯,這 樣還是多少簡單了一點。
問題解決了!
回到最初的場景中,假設您和您的團隊認為使用特定目錄是針對構建時間問 題的最具擴展性的解決方案。該任務最困難的地方是檢查及分配測試類型。您重 構了 Ant 構建文件並創建了 4 項新任務(為單個的測試類型創建了三項,為運 行所有這些測試類型創建了一項)。不僅如此,您還修改了 CruiseControl,從 而只在(代碼)簽入時運行真正的單元測試,並以小時為基礎運行組件測試。在 進一步檢查之後,發現系統測試也可以按小時運行,所以您創建了一個將組件測 試和系統測試一起運行的額外任務。
最終結果是,測試每天都運行很多次,您的團隊能夠更快地發現集成錯誤 — — 通常在幾個小時之內。
當然,創建敏捷性構建並未解決全部問題,但它在確保代碼質量方面確實扮 演了至關重要的角色。測試運行得更加頻繁了,針對開發人員測試價值的顧慮成 為一段遙遠的記憶。另外,更重要的是,現在 2006 年您的公司獲得了極大的成 功!