# 追求代碼質量: 通過測試分類實現敏捷構建
_以不同頻率運行測試來縮短構建持續期_
人人都認可開發人員測試的重要性,但為什么運行測試還是需要花費太多時間?本月,Andrew Glover 揭示了三種用來確保端到端系統健壯性的測試類型,隨后展示了如何按類型來自動排序及運行測試。即使是使用當今大型測試套件,這樣做也能顯著地減少構建時間。
如果這樣說不會(令您)很痛苦的話,請設想您是一名任職于一家 2002 年早期創建的公司的開發人員。在金錢的驅動下,您和您的團隊接到了一項任務,即使用最新且最強大的 Java? API 構建一個大型的數據驅動的 Web 應用程序。您和公司管理層都堅定不疑地相信這就是最終將被稱為_敏捷過程_ 的東西。從第一天起,您就用 JUnit 構建測試,且把它作為 Ant 構建過程的一部分盡可能頻繁地運行。還將設置一個定時任務在夜間運行構建。在接下來的某個時刻,有人會下載 CruiseControl,不斷增長的測試套件會在每次簽入時運行。
時至今日
經過過去幾年的努力,您的公司已經開發了一個龐大的代碼庫和一個同樣龐大的 JUnit 測試套件。一切都很正常,直到大約一年前,測試套件包含了 2000 個測試,同時人們開始注意到運行構建過程用時超過三個小時。在此之前的幾個月,由于 CI 服務器資源緊張,您在代碼簽入時通過 Continuous Integration(CI)停止運行單元測試,并將測試切換到夜間運行,這使得之后的早晨時間非常緊張,于是開發人員努力去弄清楚是什么出錯以及為什么出錯。
這些天,似乎測試套件整晚極少超過一次運行,為什么會這樣呢?因為它們費時太多!沒人會僅僅為了弄明白系統是否運行良好而幾個小時守在那里。此外,整個測試套件都是在晚上運行,不是嗎?
由于測試運行得太不頻繁,它們常常充滿了錯誤。因而,您和您的團隊開始質疑單元測試的價值:如果它們對代碼質量那么重要,那又為什么會讓人這么頭痛呢?你們的結論是:單元測試有其重要的作用,但必須要能用一種更為敏捷的方式運行它們。
## 嘗試測試分類
您所需要的是一個將構建轉換到一種更為敏捷狀態的策略。您需要這樣一種解決方案,使一天當中運行測試的次數超過一次,并使測試套件恢復到要用三個小時才能完 成構建之前的水平。
為完整地恢復整個測試套件,在試圖提出一個策略之前,很有必要弄清楚通用術語 “單元測試” 的含義。諸如 “我家有一個動物” 和 “我喜歡車” 這樣的表述并不很具體,“我們編寫單元測試” 也是一樣。這年頭,單元測試能代表一切。
就拿之前有關動物和車的表述來說:它們導致了更多的疑問。例如,您家有哪種動物?是一只貓、一條蜥蜴還是一頭熊?“我家有一頭熊” 和 “我家有一只貓” 截然不同。同樣,當和汽車銷售員交談時,只說 “我喜歡車” 沒什么用處。您喜歡哪種車:賽車、卡車還是旅行車?任何一個答案都能帶來截然不同的結果。
同樣,對于開發人員測試來說,按照_類型_ 將測試分類也是很有用的。這樣做能夠實現更為精確的語言,并且能使您的團隊以不同的頻率運行不同的測試類型。為了避免運行所有 “單元測試” 所需的令人恐懼的三小時構建時間,分類是關鍵。
* * *
## 三種類型
測試套件可以形象地分為三層,每一層代表一種不同的開發人員測試類型,該測試類型由其運行時間的長短決定。正如在圖 1 中看到的那樣,每一層都增加了總的構建時間,要么增加了運行時間,要么最終增加了編寫時間。
##### 圖 1.測試分類的三個層次

底層由運行時間最短的測試構成,可以想象的到,它們也最易于編寫。這些測試占用的代碼量也是最少的。頂層由更高級別的測試構成,這些測試占用了應用程序更大的部分。這些測試有一點難于編寫,執行時間也要長得多。中間層是處于這兩個極端中間的測試類型。
三種類型如下所示:
* 單元測試
* 組件測試
* 系統測試
讓我們分別來看一下。
## 改進代碼質量
別錯過 Andrew 的相關 [討論論壇](http://www.ibm.com/developerworks/forums/dw_forum.jsp?S_TACT=105AGX52&cat=10&S_CMP=cn-a-j&forum=812),里面有關于代碼語法、測試框架以及如何編寫專注于質量的代碼的幫助。
## 1\. 單元測試
單元測試_隔離地_ 驗證一個或多個對象。單元測試不處理數據庫、文件系統或任何可能延長測試運行時間的內容;因而,從第一天就可以編寫單元測試。事實上,這也正是 JUnit 設計的確切目的所在。單元測試的隔離概念有無數的模擬對象庫作后盾,這些庫便利了將一個特定的對象從其外部依賴項中隔離出來。而且,單元測試能夠在真正要測試的代碼前編寫 —— 由此有了_測試優先開發_ 的概念。
單元測試通常易于編寫,因為它們并不依靠于架構的依賴項,且通常運行得很快。缺點是,獨立的單元測試只能覆蓋稍顯有限的代碼。單元測試的重大價值在于:它們使開發人員能夠在盡可能低的層面上保證對象的可靠性。
由于單元測試運行得如此之快且如此易于編寫,代碼庫中應包含許多單元測試,并且應該盡可能多地運行它們。在執行構建時,應該_經常_ 運行它們,不管是在機器上,還是在 CI 環境的上下文中(這意味著,代碼一經簽入 SCM 環境,就要運行單元測試)。
* * *
## 2\. 組件測試
組件測試驗證多個相互作用的對象,但它突破了隔離的概念。由于組件測試處理一個架構的多個層次,所以它們經常用于處理數據庫、文件系統、網絡元素等。同樣,提前編寫組件測試有點難,所以將其包含至一個真正的測試優先/測試驅動的場景中是很大的挑戰。
編寫組件測試要花更長的時間,因為它們比單元測試所涉及的東西要多。另一方面,由于其寬廣的范圍,它們實現了比單元測試更廣的代碼覆蓋率。當然它們也要花更多時間運行,所以同時運行很多的組件測試會_顯著地_ 增加總的測試時間。
許多框架有助于測試大型架構組件。DbUnit 是這類框架的一個典型例子。DbUnit 能夠很好地處理在測試狀態間建立一個數據庫這樣的復雜性,因而它會使編寫依賴于數據庫的測試變得較為簡單。
當構建的測試延長時,通常都預示著包含了一個大型的組件測試套件。由于這些測試比真正的單元測試運行時間長,因而不能一直運行它們。相應地,在 CI 環境中這些測試可以_至少_ 每小時運行一次。在簽入任何代碼前,也應該總在一個本地開發人員機器上運行這些測試。
## 驗收測試
_驗收測試_ 和功能測試類似,不同之處在于,理想情況下,驗收測試是由客戶或最終用戶編寫的。正如功能測試一樣,驗收測試也像最終用戶測試那樣進行。Selenium(參見 [參考資料](#resources))是一個備受矚目的驗收框架,它使用瀏覽器測試 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](#listing3) 所示)。
該 JUnit 任務和 [清單 2](#listing2) 中定義的任務非常相似。所不同的是該任務 `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 年您的公司獲得了極大的成功!
- 追求代碼質量
- 追求代碼質量: 對 Ajax 應用程序進行單元測試
- 追求代碼質量: 使用 TestNG-Abbot 實現自動化 GUI 測試
- 追求代碼質量: 用 AOP 進行防御性編程
- 追求代碼質量: 探究 XMLUnit
- 追求代碼質量: 用 JUnitPerf 進行性能測試
- 追求代碼質量: 通過測試分類實現敏捷構建
- 追求代碼質量: 可重復的系統測試
- 追求代碼質量: JUnit 4 與 TestNG 的對比
- 追求代碼質量: 馴服復雜的冗長代碼
- 追求代碼質量: 用代碼度量進行重構
- 追求代碼質量: 軟件架構的代碼質量
- 讓開發自動化: 除掉構建腳本中的氣味
- 追逐代碼質量: 決心采用 FIT
- 追求代碼質量: 不要被覆蓋報告所迷惑