軟體開發習慣中一個細微更改都可能會對軟體質量產生巨大改進.將單元測試合併到開發過程中,然後從長遠角度來看它可以節省多少時間和精力.本文通過使用代碼樣本說明了單元測試的種種好處,特別是使用 Ant 和 JUnit 帶來的各種方便.
測試是大型開發過程中的基本原則之一.在任何職業中,驗證都是一個重要部分.醫生要通過驗血來確診.波音公司在研製 777 的過程中對飛機的每個組件都進行了精心測試.為什麼軟體開發就應該例外呢?
以前,由於在應用程序中將 GUI 和商業邏輯緊密聯繫在一起,這就限制了創建自動測試的能力.當我們學會通過抽象層將商業邏輯從界面中分離出來時,各個單獨代碼模塊的自動測試就替代了通過 GUI 進行的手工測試.
現在,集成開發環境 (IDE) 能在您輸入代碼的同時顯示錯誤,對於在類中快速查找方法具有智能探測功能,可以利用語法結構生成彩色代碼,具有許多其它功能.因此,在編譯更改過的代碼之前,您已經全盤考慮了將構建的類,但您是否考慮過這樣的修改會破壞某些功能呢?
每個開發者都碰到過更改「臭蟲」.代碼修改過程可能會引入「臭蟲」,而如果通過用戶界面手工測試代碼的話,在編譯完成之前是不會發現它的.然後,您就要花費幾天的時間追蹤由更改所引起的錯誤.最近在我做的一個項目中,當我把後端資料庫由 Informix 更改到 Oracle 時就遇到了這種情況.大部分更改都十分順利,但由於資料庫層或使用資料庫層的系統缺少單元測試,從而導致將大量時間花費在嘗試解決更改「臭蟲」上.我花了兩天的時間查到別人代碼中的一個資料庫語法更改.(當然,那個人仍是我的朋友.)
儘管測試有許多好處,但一般的程序員對測試都不太感興趣,開始時我也沒有.您聽到過多少次「它編譯了,它一定能用」這種言論?但「我思,故我在」這種原則並不適用於高質量軟體.要鼓勵程序員測試他們的代碼,過程必須簡單無痛.
本文從某人學慣用 Java 語言編程時所寫的一個簡單的類開始.然後,我會告訴您我是如何為這個類編寫單元測試,以及在編寫完它以後又是如何將單元測試添加到構建過程中的.,我們將看到將「臭蟲」引入代碼時發生的情況.
從一個典型類開始
第一個典型的 Java 程序一般都包含一個列印 "Hello World" 的 main().在清單 1 中,我創建了一個 HelloWorld 對象的實例並調用 sayHello() 方法,該方法會列印這句習慣說法.
清單 1. 我的第一個 Java 應用程序 "Hello world"
/* * HelloWorld.java * My first java program */ class HelloWorld { /** * Print "Hello World" */ void sayHello() { System.out.println("Hello World"); } /** * Test */ public static void main( String[] args ) { HelloWorld world = new HelloWorld(); world.sayHello(); } } |
main() 方法是我的測試.哦噢!我將代碼、文檔、測試和樣本代碼包含在了一個模塊中.保佑 Java!但隨著程序越變越大,這種開發方法很快就開始顯現出了缺陷:
混亂
類介面越大,main() 就越大.類可能僅僅正常的測試而變得非常龐大.
代碼膨脹
由於加入了測試,產品代碼比所需要的要大.但我不想交付測試,而只想交付產品.
測試不可靠
既然 main() 是代碼的一部分,main() 就對其他開發者通過類介面無法訪問的私有成員和方法享有訪問權.出於這個原因,這種測試方法很容易出錯.
很難自動測試
要進行自動測試,我仍然必須創建另一程序來將參數傳遞給 main().
類開發
對我來說,類開發是從編寫 main() 方法開始的.我在編寫 main() 的時候就定義類和類的用法,然後實現介面.它的一些明顯的缺陷也開始顯現出來.一個缺陷是我傳遞給 main() 來執行測試的參數個數.其次,main() 本身在進行調用子方法、設置代碼等操作時變得很混亂.有時 main() 會比類實現的其餘部分還要大.
更簡單的過程
我原來的做法有一些很明顯的缺陷.因此,讓我們看看有什麼別的方法可以使問題簡化.我仍然通過介面設計代碼並給出應用示例,正如原來的 main() 一樣.不同的是我將代碼放到了另一個單獨的類中,而這個類恰好是我的「單元測試」.這種技術有以下幾點好處:
設計類的一種機制
是通過介面進行開發,
不太可能利用類的內部功能.但
我是目標類的開發者,我有到其內部工作的「窗口」,
測試並不是個真正的黑箱.僅憑這一點就足夠推斷出需要開發者本人在編寫目標類的同時負責測試的開發,而不是由其他任何人代勞.
類用法的示例
通過將示例從實現中分離出來,開發者可以更快地提高速度,再不用在源代碼上糾纏不清.這種分離還有助於防止開發者利用類的內部功能,
這些功能將來可能已經不存在了.
沒有類混亂的 main()
我不再受到 main() 的限制了.以前我得將多個參數傳遞給 main() 來測試不同的配置.現在我可以創建許多單獨的測試類,每一個都維護各自的設置代碼.
接下來我們將這個單獨的單元測試對象放入構建過程中.這樣,我們就可以提供自動確認過程的方法.
確保所做的任何更改都不會對其他人產生不利影響.
我們在進行源碼控制之前就可以測試代碼,而無需等待彙編測試或在夜晚進行的構建測試.這有助於儘早捕捉到「臭蟲」,從而降低產生高質量代碼的成本.
通過提供增量測試過程,我們提供了更好的實現過程.如同 IDE 幫助我們在輸入時捕捉到語法或編譯「臭蟲」一樣,增量單元測試也幫助我們在構建時捕捉到代碼更改「臭蟲」.
使用 JUnit 自動化單元測試
要使測試自動化,您需要一個測試框架.您可以自己開發或購買,也可以使用某些開放源代碼工具,例如 JUnit.我選擇 JUnit 出於以下幾個原因:
不需要編寫自己的框架.
它是開放源代碼,因此不需要購買框架.
開放源代碼社區中的其他開發者會使用它,因此可以找到許多示例.
它可以讓我將測試代碼與產品代碼分開.
它易於集成到我的構建過程中.
測試布局
圖 1 顯示了使用樣本 TestSuite 的 JUnit TestSuite 布局.每個測試都由若干單獨的測試案例構成.每個測試案例都是一個單獨的類,它擴展了 TestClass 類並包含了我的測試代碼,即那些曾在 main() 中出現的代碼.在該例中,我向 TestSuite 添加了兩個測試:一個是 SkeletonTest,我將它用作所有新類和 HelloWorld 類的起點.
圖 1. TestSuite 布局
測試類 HelloWorldTest.java
按照約定,測試類的名稱中包含我所測試的類的名稱,但將 Test 附加到結尾.在本例中,我們的測試類是 HelloWorldTest.java.我複製了 SkeletonTest 中的代碼,並添加了 testSayHello() 來測試 sayHello().請注意 HelloWorldTest 擴展了 TestCase.JUnit 框架提供了 assert 和 assertEquals 方法,我們可以使用這些方法來進行驗證.HelloWorldTest.java 顯示在清單 2 中.
清單 2. HelloWorldTest.java
package test.com.company; import com.company.HelloWorld; import junit.framework.TestCase; import junit.framework.AssertionFailedError; /** * JUnit 3.2 testcases for HelloWorld */ public class HelloWorldTest extends TestCase { public HelloWorldTest(String name) { super(name); } public static void main(String args[]) { junit.textui.TestRunner.run(HelloWorldTest.class); } public void testSayHello() { HelloWorld world = new HelloWorld(); assert( world!=null ); assertEquals("Hello World", world.sayHello() ); } } |
testSayHello() 看上去和 HelloWorld.java 中原來的 main 方法類似,但有一個主要的不同之處.它不是執行 System.out.println 並顯示結果,而是添加了一個 assertEquals() 方法.如果兩個值不同,assertEquals 將列印出兩個輸入的值.您可能已經注意到這個方法不起作用!HelloWorld 中的 sayHello() 方法不返回字元串.如果我先寫過測試,就會捕捉到這一點.我將 "Hello World" 字元串與輸出流聯結起來.這樣,按照清單 3 中顯示的那樣重寫了 HelloWorld,去掉 main(),並更改了 sayHello() 的返回類型.
清單 3. Hello world 測試案例.
package com.company; public class HelloWorld { public String sayHello() { return "Hello World"; } } |
如果我保留了 main() 並修改了聯繫,代碼看上去如下:
public static void main( String[] args ) { HelloWorld world = new HelloWorld(); System.out.println(world.sayHello()); } |
新的 main() 與我測試程序中的 testSayHello() 非常相似.是的,它看上去不象是一個現實世界中的問題(這是人為示例的問題),但它說明了問題.在單獨的應用程序中編寫 main() 可以改進您的設計,同時幫助您設計測試.現在我們已經創建了一個測試類,讓我們使用 Ant 來將它集成到構建中.
使用 Ant 將測試集成到構建中
Jakarta Project 將 Ant 工具說成「不帶 make 缺點的 make」.Ant 正在成為開放源代碼世界中實際上的標準.原因很簡單:Ant 是使用 Java 語言編寫的,這種語言可以讓構建過程在多種平台上使用.這種特性簡化了在不同 OS 平台之間的程序員的合作,而合作是開放源代碼社區的一種需要.您可以在自己選擇的平台上進行開發和構建.Ant 的特性包括:
類可擴展性
Java 類可用於擴展構建特性,而不必使用基於 shell 的命令.
開放源代碼
Ant 是開放源代碼,因此類擴展示例很充足.我發現通過示例來學習非常棒.
XML 可配置
Ant 不僅是基於 Java 的,它還使用 XML 文件配置構建過程.假設構建實際上是分層的,那麼使用 XML 描述 make 過程就是其邏輯層.另外,如果您了解 XML,要學習如何配置構建就更簡單一些.
圖 2 簡要介紹了一個配置文件.配置文件由目標樹構成.每個目標都包含了要執行的任務,其中任務就是可以執行的代碼.在本例中,mkdir 是目標 compile 的任務.mkdir 是建立在 Ant 中的一個任務,用於創建目錄. Ant 帶有一套健全的內置任務.您也可以通過擴展 Ant 任務類來添加自己的功能.
每個目標都有唯一的名稱和可選的相關性.目標相關性需要在執行目標任務列表之前執行.例如圖 2 所示,在執行 compile 目標中的任務之前需要先運行 JUNIT 目標.這種類型的配置可以讓您在一個配置中有多個樹.
圖 2. Ant XML 構建圖
與經典 make 實用程序的相似性是非常顯著的.這是理所當然的, make 就是 make.但也要記住有一些差異:通過 Java 實現的跨平台和可擴展性,通過 XML 實現的可配置,還有開放源代碼.
下載和安裝 Ant
下載 Ant(請參閱參考資料).將 Ant 解壓縮到 tools 目錄,再將 Ant bin 目錄添加到路徑中.(在我的機器上是 e:toolsantbin.)設置 ANT_HOME 環境變數.在 NT 中,這意味著進入系統屬性,然後以帶有值的變數形式添加 ANT_HOME.ANT_HOME 應該設置為 Ant 根目錄,即包含 bin 和 lib 目錄的目錄.(對我來說,是 e:toolsant.)確保 JAVA_HOME 環境變數設置為安裝了 JDK 的目錄.Ant 文檔有關於安裝的詳細信息.
下載和安裝 JUnit
下載 JUnit 3.2(請參閱參考資料). 解開 junit.zip,並將 junit.jar 添加到 CLASSPATH.如果將 junit.zip 解包到類路徑中,可以通過運行以下命令來測試安裝:
java junit.textui.TestRunner junit.samples.AllTests
定義目錄結構
在開始我們的構建和測試過程之前,需要一個項目布局.圖 3 顯示了我的樣本項目的布局.下面描述了布局的目錄結構:
build —— 類文件的臨時構建位置.構建過程將創建這個目錄.
src —— 源代碼的位置.Src 被分為 test 文件夾和 main 文件夾,前者用於所有的測試代碼,而後者包含可交付的代碼.將測試代碼與主要代碼分離提供了幾點特性.,使主要代碼中的混亂減少.其次,它允許包對齊.我就熱衷與將類和與其相關的包放置在一起.測試就應該和測試在一起.它還有助於分發過程,
你不可能打算將單元測試分發給客戶.
在實際中,我們有多個目錄,例如 distribution 和 documentation.我們還會在 main 下有多個用於包的目錄,例如 com.company.util.
目錄結構經常變動,
在 build.xml 中有這些變動的全局字元串常數是很重要的.
圖 3. 項目布局圖
Ant 構建配置文件示例
下一步,我們要創建配置文件.清單 4 顯示了一個 Ant 構建文件示例.構建文件中的關鍵就是名為 runtests 的目標.這個目標進行分支判斷並運行外部程序,其中外部程序是前面已安裝的 junit.textui.TestRunner.我們指定要使用語句 test.com.company.AllJUnitTests 來運行哪個測試套件.
清單 4. 構建文件示例
<property name="app.name" value="sample" /> <property name="build.dir" value="build/classes" /> <target name="JUNIT"> <available property="junit.present" classname="junit.framework.TestCase" /> </target> <target name="compile" depends="JUNIT"> <mkdir dir="${build.dir}"/> <javac srcdir="src/main/" destdir="${build.dir}" > <include name="**/*.java"/> </javac> </target> <target name="jar" depends="compile"> <mkdir dir="build/lib"/> <jar jarfile="build/lib/${app.name}.jar" basedir="${build.dir}" includes="com/**"/> </target> <target name="compiletests" depends="jar"> <mkdir dir="build/testcases"/> <javac srcdir="src/test" destdir="build/testcases"> <classpath> <pathelement location="build/lib/${app.name}.jar" /> <pathelement path="" /> </classpath> <include name="**/*.java"/> </javac> </target> <target name="runtests" depends="compiletests" if="junit.present"> <java fork="yes" classname="junit.textui.TestRunner" taskname="junit" failonerror="true"> <arg value="test.com.company.AllJUnitTests"/> <classpath> <pathelement location="build/lib/${app.name}.jar" /> <pathelement location="build/testcases" /> <pathelement path="" /> <pathelement path="${java.class.path}" /> </classpath> </java> </target> </project> |
運行 Ant 構建示例
開發過程中的下一步是運行將創建和測試 HelloWorld 類的構建.清單 5 顯示了構建的結果,其中包括了各個目標部分.最酷的那部分是 runtests 輸出語句:它告訴我們整個測試套件都正確運行了.
我在圖 4 和圖 5 中顯示了 JUnit GUI,其中所要做的就是將 runtest 目標從junit.textui.TestRunner 改為 junit.ui.TestRunner.當您使用 JUnit 的 GUI 部分時,您必須選擇退出按鈕來繼續構建過程.如果使用 Junit GUI 構建包,那麼它將更難與大型的構建過程相集成.另外,文本輸出也與構建過程更一致,並可以定向輸出到一個用於主構建記錄的文本文件.這對於每天晚上都要進行的構建非常合適.
清單 5. 構建輸出示例
E:projectssample>ant runtests Searching for build.xml ... Buildfile: E:projectssamplebuild.xml JUNIT: compile: [mkdir] Created dir: E:projectssamplebuildclasses [javac] Compiling 1 source file to E:projectssamplebuildclasses jar: [mkdir] Created dir: E:projectssamplebuildlib [jar] Building jar: E:projectssamplebuildlibsample.jar compiletests: [mkdir] Created dir: E:projectssamplebuildtestcases [javac] Compiling 3 source files to E:projectssamplebuildtestcases runtests: [junit] .. [junit] Time: 0.031 [junit] [junit] OK (2 tests) [junit] BUILD SUCCESSFUL Total time: 1 second |
圖 4. JUnit GUI 測試成功
圖 5. JUnit GUI 測試失敗
了解測試的工作原理
讓我們搞點破壞,然後看看會發生什麼事.夜深了,我們決定把 "Hello World" 變成一個靜態字元串.在更改期間,我們不小心打錯了字母,將 "o" 變成了 "0",如清單 6 所示.
清單 6. Hello world 類更改
package com.company; public class HelloWorld { private final static String HELLO_WORLD = "Hell0 World"; public String sayHello() { return HELLO_WORLD; } } |
在構建包時,我們看到了錯誤.清單 7 顯示了 runtest 中的錯誤.它顯示了失敗的測試類和測試方法,並說明了為什麼會失敗.我們返回到代碼中,改正錯誤后離開.
清單 7. 構建錯誤示例
E:projectssample>ant runtests Searching for build.xml ... Buildfile: E:projectssamplebuild.xml JUNIT: compile: jar: compiletests: runtests: [junit] ..F [junit] Time: 0 [junit] [junit] FAILURES!!! [junit] Test Results: [junit] Run: 2 Failures: 1 Errors: 0 [junit] There was 1 failure: [junit] 1) testSayHello(test.com.company.HelloWorldTest) " |
並非完全無痛
新的過程並不是完全無痛的.為使單元測試成為開發的一部分,您必須採取以下幾個步驟:
下載和安裝 JUnit.
下載和安裝 Ant.
為構建創建單獨的結構.
實現與主類分開的測試類.
學習 Ant 構建過程.
但好處遠遠超過了痛苦.通過使單元測試成為開發過程的一部分,您可以:
自動驗證以捕捉更改「臭蟲」
從介面角度設計類
提供乾淨的示例
在發行包中避免代碼混亂和類膨脹.
[火星人 ] 利用 Ant 和 JUnit 進行增量開發已經有656次圍觀