我是測試驅動開發(test-driven development,TDD)的大力支持者。Neal Ford(The Productive Programmer 的作者)說道 “不測試所編寫的代碼就是失職”。Michael Feathers(Working Effectively with Legacy Code 的作者)將 “遺留代碼” 定義為沒有經過相應測試的任何軟件 — 這 表明編寫代碼而不進行測試是一種過時的實踐。我常說每編寫一定數量的生產代碼,就要編寫兩倍的測試 代碼。
精通 Grails 尚未討論 TDD,因為到目前為止,這個系列主要關注如何利用 Grails 的核心功能。測 試基礎設施代碼(不用您編寫的代碼)有一定的價值,但我很少這樣做。我相信 Grails 能夠正確地將我 的 POGO 呈現為 XML,或在我調用 trip.save() 時將我的 Trip 保存到數據庫。當您檢查自己編寫的代 碼時,測試的真正價值就體現出來了。如果您編寫一個復雜的算法,您應該有一個或多個補充單元測試, 確保該算法正常工作。在本文,您將看到 Grails 如何幫助和鼓勵您進行應用程序測試。
編寫第一個測試
在開始測試之前,我將介紹一個新的域類。這個類的一些定制功能必須經過測試才能進入到生產中。 輸入 grails create-domain-class HotelStay,如清單 1 所示:
清單 1. 創建 HotelStay 類
$ grails create-domain-class HotelStay
Environment set to development
[copy] Copying 1 file to /src/trip-planner2/grails-app/domain
Created Domain Class for HotelStay
[copy] Copying 1 file to /src/trip-planner2/test/integration
Created Tests for HotelStay
從清單 1 可以看到,Grails 在 grails-app/domain 目錄中為您創建了一個空的域類。它還在 test/integration 目錄中創建了一個帶有空的 testSomething() 方法的 GroovyTestCase 類(稍後我將 進一步講述單元測試和集成測試的區別)。清單 2 展示了一個帶有生成的測試的空 HotelStay 類:
清單 2. 帶有生成的測試的空類
class HotelStay {
}
class HotelStayTests extends GroovyTestCase {
void testSomething() {
}
}
GroovyTestCase 是在 JUnit 3.x 單元測試之上的一層 Groovy。如果您熟悉 JUnit TestCase,您肯 定知道 GroovyTestCase 是如何工作的。對於這兩種情況,您通過斷言代碼正常工作來測試它們。JUnit 有各種不同的斷言方法,包括 assertEquals、assertTrue 和 assertNull 等等。它使您通過編程的方式 表明 “我斷言這個代碼按照預期工作”。
為什麼是 JUnit 3.x 而不是 4.x?
由於歷史原因,GroovyTestCase 就是一個 JUnit 3.x TestCase。當 Groovy 1.0 於 2007 年 1 月發 布時,它支持 Java 1.4 語言結構。它可以在 Java 1.4、1.5 和 1.6 JVM 上運行,但在語言級別上僅與 Java 1.4 兼容。
接下來 Groovy 的主要發布版是 1.5,在 2008 年 1 月發布。Groovy 1.5 支持所有 Java 1.5 語言 特性,比如泛型、靜態導入、for/in 循環和注釋(後者最值得討論)。不過 Groovy 1.5 仍然可以在 Java 1.4 JVM 上運行。Groovy 開發團隊許諾所有 Groovy 1.x 版本都將與 Java 1.4 保持向後兼容性。 當 Groovy 2.x 發布時(可能是 2009 年末或 2010 年),它將不支持 Java 1.4。
因此,這些與 GroovyTestCase 打包的 JUnit 版本有什麼關系呢?JUnit 4.x 引入了一些注釋,比如 @test、@before 和 @after。盡管這些新特性非常有趣,但 JUnit 3.x 仍然是 GroovyTestCase 向後兼 容 Java 1.4 的基礎。
這就是說,您完全可以使用 JUnit 4.x(參見 參考資料 獲得 Groovy 站點相關文檔的鏈接)。引入 其他使用注釋和 Java 5 語言特性的測試框架是完全有可能的(參見 參考資料 獲得結合使用 TestNG 和 Groovy 的示例)。Groovy 的字節碼與 Java 編程兼容,因此您可以通過 Groovy 使用任何 Java 測試框 架。
將清單 3 中的代碼添加到 grails-app/domain/HotelStay.groovy 和 test/integration/HotelStayTests.groovy:
清單 3. 一個簡單的測試
class HotelStay{
String hotel
}
class HotelStayTests extends GroovyTestCase {
void testSomething(){
HotelStay hs = new HotelStay(hotel:"Sheraton")
assertEquals "Sheraton", hs.hotel
}
}
清單 3 正是我前面提到那種低級 Grails 基礎設施測試。您應該相信 Grails 能夠正確執行這個操作 ,因此這是一個典型的錯誤測試類型。但它允許您編寫最簡單的測試並觀察其運行,實現了本文的目的。
要運行所有測試,請輸入 grails test-app。要僅運行這個測試,請輸入 grails test-app HotelStay(由於約定優於配置,Tests 後綴可以省略)。不管輸入哪個命令,您應該會在命令提示中看 到如清單 4 所示的輸出(注意:為了突出重要的特性,我刪減了許多代碼)。
清單 4. 運行測試時的輸出
$ grails test-app
Environment set to test
No tests found in test/unit to execute ...
-------------------------------------------------------
Running 1 Integration Test...
Running test HotelStayTests...
testSomething...SUCCESS
Integration Tests Completed in 253ms
-------------------------------------------------------
Tests passed. View reports in /src/trip-planner2/test/reports
這裡發生了 4 件重要的事情:
可以看到,environment 被設置為 test。這意味著 conf/DataSource.groovy 文件中的 test 塊的數 據庫設置已生效。
test/unit 中的腳本已運行。您尚未編寫任何單元測試,所以不能找到任何單元測試,這並不奇怪。
test/integration 中的腳本已經運行。您可以看到 HotelStayTests.groovy 腳本的輸出 — 它的旁 邊有個很大的 SUCCESS。
這個腳本向您展示一組報告。
如果您在 Web 浏覽器中打開 /src/trip-planner2/test/reports/html/index.html,應該會看到一個 關於所有已運行的測試的報告。如圖 1 所示。
圖 1. JUnit 頂級匯總報告
如果您單擊 HotelStayTests 鏈接,應該會看到 doSomething() 測試,如圖 2 所示:
圖 2. JUnit 類級報告
如果測試意外失敗,命令提示輸出和 HTML 報告(如圖 3 所示)將通知您:
圖 3. 失敗的 JUnit 測試
編寫第一個有價值的測試
以上是第一個正常運行的簡單測試,接下來將展示一個更加實用的測試示例。假設您的 HotelStay 類 有兩個字段:Date checkIn 和 Date checkOut。根據一個用戶情景,toString 方法的輸出應該像這樣: Hilton (Wednesday to Sunday)。通過 java.text.SimpleDateFormat 類,獲取正確格式的日期非常簡單 。您應該為此編寫一個測試,但不需驗證 SimpleDateFormat 是否正確工作。您的測試做兩件事情:它驗 證 toString 方法是否按照預期運行;它證明您是否滿足用戶情景。
單元測試是可執行的文檔
用戶需求常常是桌面上的某些文檔。作為開發人員,您應該將這些需 求轉換成有效的軟件。
需求文檔的問題是:在進行實際軟件開發時它通常已經過時。它不是可以 隨著軟件的發展而變化的 “活動文檔”。工件 一詞完美地描述了這種情況 — 文檔描 述軟件最初的、歷史性的任務是什麼,而不是當前實現要做什麼。
要想准備一組全面的、優秀的 測試,僅僅保持代碼沒有 bug 是不夠的。這樣的測試有一個附帶的好處,即您可以得到 “可執行 的文檔”:用代碼表示活動的、不斷變化的項目需求。如果將測試映射到需求,則可以和用戶共享 某些內容。您必須保證代碼的健全,保證滿足了用戶的需求。將這個可執行文檔與 CruiseControl 等持 續集成服務器(持續反復地運行測試的服務器)相結合,就可以得到一個安全保障機制,它保證新特性不 會對原本良好的軟件造成損害。
行為驅動的開發(Behavior-Driven Development,BDD)完全采 用了可執行文檔的想法。easyb 是一個用 Groovy 編寫的 BDD,它允許您將測試編寫成用戶和開發人員都 可以閱讀的用戶需求(參見 參考資料)。如果一些用戶思想比較前衛,寧願放棄 Microsoft® Word (例如),easyb 可以排除所有過時的需求文檔。因此,項目需求從一開始就是可執行的。
將清 單 5 中的代碼輸入到 HotelStay.groovy 和 HotelStayTests.groovy:
清單 5. 使用 assertToString
import java.text.SimpleDateFormat
class HotelStay {
String hotel
Date checkIn
Date checkOut
String toString(){
def sdf = new SimpleDateFormat("EEEE")
"${hotel} (${sdf.format(checkIn)} to ${sdf.format(checkOut)})"
}
}
import java.text.SimpleDateFormat
class HotelStayTests extends GroovyTestCase {
void testSomething(){...}
void testToString() {
def h = new HotelStay (hotel:"Hilton")
def df = new SimpleDateFormat("MM/dd/yyyy")
h.checkIn = df.parse("10/1/2008")
h.checkOut = df.parse ("10/5/2008")
println h
assertToString h, "Hilton (Wednesday to Sunday)"
}
}
輸入 grails test-app 驗證第二個測試是否通過。
testToString 方法使用了新的斷言方法之一 —assertToString— 它由 GroovyTestCase 引入。使用 JUnit assertEquals 方法肯定會獲得相同的結果,但是 assertToString 的表達能力更強。測試方法的 名稱和最終的斷言清楚地表明了這個測試的目的(參見 參考資料 獲得一個鏈接,它列出了 GroovyTestCase 支持的所有斷言,包括 assertArrayEquals、assertContains 和 assertLength)。
添加控制器和視圖
到目前為止,您一直以編程的方式與 HotelStay 域類交互。添加一個 HotelStayController,如清單 6 所示,它使您能夠在 Web 浏覽器上使用該類:
清單 6. HotelStayController 源代碼
class HotelStayController {
def scaffold = HotelStay
}
您應該對 create 表單進行仔細的 UI 調試。默認情況下,日期字段包括 day、month、year、hours 和 minutes,如圖 4 所示:
圖 4. 默認顯示日期和時間
在這裡,忽略日期字段的時間戳部分是安全的。輸入 grails generate-views HotelStay。要創建圖 5 所示的經過修改的 UI,請將 precision="day" 添加到 views/hotelStay/create.gsp 和 views/hotelStay/edit.gsp 中的 <g:datePicker> 元素:
圖 5. 僅顯示日期
有了運行在 servlet 容器中的活動的、有效的 HotelStay 之後,就要開始討論測試了:單元測試還 是集成測試?
對比單元測試和集成測試
如我前面所述,Grails 支持兩種基本類型的測試:單元測試和集成測試。這兩者之間沒有語法區別 — 它們都是用相同的斷言寫的 GroovyTestCase。它們的區別在於語義。單元測試孤立地測試類,而集成 測試在一個完整的運行環境中測試類。
坦白地說,如果您想將所有的 Grails 測試都編寫成集成測試,則剛好符合我的想法。所有 Grails create-* 命令都生成相應的集成測試,所以很多人都使用現成的集成測試。正如稍後看到的一樣,很多 測試需要在完整的運行環境中進行,因此默認使用集成測試是很好的選擇。
如果您想測試一些非核心 Grails 類,則適合使用單元測試。要創建一個單元測試,請輸入 grails create-unit-test MyTestUnit。因為測試腳本不是在不同的包中創建的,所以單元測試和集成測試的名 稱應該是惟一的。如果不是這樣的話,將會收到清單 7 所示的錯誤消息:
清單 7. 單元測試和集成測試同名時收到的錯誤消息
The sources
/src/trip-planner2/test/integration/HotelStayTests.groovy and
/src/trip-planner2/test/unit/HotelStayTests.groovy are
containing both a class of the name HotelStayTests.
@ line 3, column 1.
class HotelStayTests extends GroovyTestCase {
^
1 error
因為集成測試默認使用後綴 Tests,所以我在所有單元測試上都使用後綴 UnitTests,避免混淆。
為簡單的驗證錯誤消息編寫測試
下一個用戶場景說明 hotel 字段不能留空。這很容易通過內置的 Grails 驗證框架來實現。將一個 static constraints 塊添加到 HotelStay,如清單 8 所示:
清單 8. 將一個 static constraints 塊添加到 HotelStay
class HotelStay {
static constraints = {
hotel(blank:false)
checkIn()
checkOut()
}
String hotel
Date checkIn
Date checkOut
//the rest of the class remains the same
}
輸入 grails run-app。如果您嘗試在留空 hotel 字段的情況下創建一個 HotelStay,將收到如圖 6 所示的錯誤消息:
圖 6. 空字段的默認錯誤消息
我敢保證您的用戶會喜歡這個特性,但對默認的錯誤消息還不是很滿意。假設他們稍微改動了一下用 戶場景:hotel 字段不能留空;如果留空,錯誤消息會提示 “Please provide a hotel name”。
現在您已經添加了一些定制代碼 — 盡管它就像一個定制的 String 那麼簡單 — 接下來應該添加測 試了(當然,編寫一個驗證用戶場景的完整性的測試 — 盡管不涉及到定制代碼 — 也是完全可以接受的 。
打開 grails-app/i18n/messages.properties 並添加 hotelStay.hotel.blank=Please provide a hotel name。嘗試在浏覽器中提交一個空 hotel。這時您將看到自己的定制消息,如圖 7 所示:
圖 7. 顯示定制的驗證錯誤消息
向 HotelStayTests.groovy 添加一個新測試,檢驗對空字段的驗證是否有效,如清單 9 所示:
清單 9. 測試驗證錯誤
class HotelStayTests extends GroovyTestCase {
void testBlankHotel(){
def h = new HotelStay(hotel:"")
assertFalse "there should be errors", h.validate()
assertTrue "another way to check for errors after you call validate()", h.hasErrors ()
}
//the rest of the tests remain unchanged
}
在生成的控制器中,您已經看到添加到域類中的 save() 方法。在這裡,我本來也可以調用 save(), 但事實上我並不想把新的類保存到數據庫。我只關注驗證是否發生。由 validate() 方法來完成這個任務 。如果驗證失敗,則返回 false。如驗證成功,則返回 true。
hasErrors() 是另一個很有價值的測試方法。在調用 save() 或 validate() 之後,hasErrors() 允 許您查看驗證錯誤。
清單 10 是經過擴展的 testBlankHotel(),它引入了其他一些很有用的驗證方法:
清單 10. 驗證錯誤的高級測試
class HotelStayTests extends GroovyTestCase {
void testBlankHotel(){
def h = new HotelStay(hotel:"")
assertFalse "there should be errors", h.validate()
assertTrue "another way to check for errors after you call validate()", h.hasErrors()
println "\nErrors:"
println h.errors ?: "no errors found"
def badField = h.errors.getFieldError('hotel')
println "\nBadField:"
println badField ?: "hotel wasn't a bad field"
assertNotNull "I'm expecting to find an error on the hotel field", badField
def code = badField?.codes.find {it == 'hotelStay.hotel.blank'}
println "\nCode:"
println code ?: "the blank hotel code wasn't found"
assertNotNull "the blank hotel field should be the culprit", code
}
}
確定類沒有通過驗證之後,您可以調用 getErrors() 方法(在這裡,借助 Groovy 簡潔的 getter 語 法,它被縮略為 errors),返回一個 org.springframework.validation.BeanPropertyBindingResult。 就像 GORM 與 Hibernate 相比是一個瘦 Groovy 層一樣,Grails 驗證只不過是一個簡單的 Spring 驗證 。
調用 println 的結果不會在命令行上顯示,但它們出現在 HTML 報告中,如圖 8 所示:
圖 8. 查看測試的 println 輸出
在 HotelStayTests 報告的右下角單擊 System.out 鏈接。
清單 10 中給人親切感覺的 Elvis 操作符(轉過臉來 — 看見他向後梳起的發型和那雙眼睛嗎?)是 一個縮略的 Groovy 三元操作符。如果 ?: 左邊的對象為 null,將使用右邊的值。
將 hotel 字段更改為 "Holiday Inn" 並重新運行測試。您將在 HTML 報告中看到另一個 Elvis 輸出 ,如圖 9 所示:
圖 9. 測試輸出中的 Elvis
看見 Elvis 之後,不要忘記清空 hotel 字段 — 如果您不希望留下中斷的測試的話。
如果仍然顯示關於 checkIn 和 checkOut 的驗證錯誤,您不必擔心。就這個測試而言,您完全可以忽 略它們。但是這表明您不應該僅測試錯誤是否出現 — 您應該確保特定的 錯誤被拋出。
注意,我沒有斷言定制錯誤消息的確切文本。為什麼我上一次關注匹配的字符串(測試 toString 的 輸出時)而這一次沒有關注?toString 方法的定制輸出便是上一個測試的目的。這一次,我更關心的是 確定驗證代碼的執行,而不是 Grails 是否正確呈現消息。這表明測試更像一門藝術,而不是科學(如果 我想驗證准確的消息輸出,則應該使用 Web 層測試工具,比如 Canoo WebTest 或 ThoughtWorks Selenium)。
創建和測試定制驗證
現在,應該處理下一個用戶場景了。您需要確保 checkOut 日期發生在 checkIn 日期之後。要解決這 個問題,您需要編寫一個定制驗證。編寫完之後,要驗證它。
將清單 11 中的定制驗證代碼添加到 static constraints 塊:
清單 11. 一個定制的驗證
class HotelStay {
static constraints = {
hotel(blank:false)
checkIn()
checkOut(validator:{val, obj->
return val.after(obj.checkIn)
})
}
//the rest of the class remains the same
}
val 變量是當前的字段。obj 變量表示當前的 HotelStay 實例。Groovy 將 before() 和 after() 方 法添加到所有 Date 對象,所以這個驗證僅返回 after() 方法調用的結果。如果 checkOut 發生在 checkIn 之後,驗證返回 true。否則,它返回 false 並觸發一個錯誤。
現在,輸入 grails run-app。確保不能創建一個 checkOut 日期早於 checkIn 日期的新 HotelStay 實例。如圖 10 所示:
圖 10. 默認的定制驗證錯誤消息
打開 grails-app/i18n/messages.properties,並向 checkOut 字段添加一個定制驗證消息: hotelStay.checkOut.validator.invalid=Sorry, you cannot check out before you check in。
保存 messages.properties 文件並嘗試保存有缺陷的 HotelStay。您將看到如清單 11 所示的錯誤消 息:
清單 11. 定制驗證錯誤消息
現在應該編寫測試了,如清單 12 所示:
清單 12. 測試定制的驗證
import java.text.SimpleDateFormat
class HotelStayTests extends GroovyTestCase {
void testCheckOutIsNotBeforeCheckIn(){
def h = new HotelStay(hotel:"Radisson")
def df = new SimpleDateFormat("MM/dd/yyyy")
h.checkIn = df.parse("10/15/2008")
h.checkOut = df.parse("10/10/2008")
assertFalse "there should be errors", h.validate()
def badField = h.errors.getFieldError('checkOut')
assertNotNull "I'm expecting to find an error on the checkOut field", badField
def code = badField?.codes.find {it == 'hotelStay.checkOut.validator.invalid'}
assertNotNull "the checkOut field should be the culprit", code
}
}
測試定制的 TagLib
接下來是最後一個需要處理的用戶場景。您已經在 create 和 edit 視圖中成功地處理了 checkIn 和 checkOut 的時間戳部分,但它在 list 和 show 視圖中仍然是錯誤的,如圖 12 所示:
圖 12. 默認的 Grails 日期輸入(包括時間戳)
最簡單的解決辦法是定義一個新的 TagLib。您可以利用 Grails 已經定義的 <g:formatDate> 標記,但創建一個自己的定制標記也很容易。我想創建一個可以以兩種方式使用的 <g:customDateFormat> 標記。
一種形式的 <g:customDateFormat> 標記打包一個 Date,並接受一個接受任何有效 SimpleDateFormat 模式的定制格式屬性:
<g:customDateFormat format="EEEE">${new Date()}</g:customDateFormat>
因為大多數用例都以美國的 “MM/dd/yyyy” 格式返回日期,所以如果沒有特別指定,我將采用這種 格式:
<g:customDateFormat>${new Date()}</g:customDateFormat>
現在,您已經知道了每個用戶場景的需求,那麼請輸入 grails create-tag-lib Date(如清單 13 所 示),以創建一個全新的 DateTagLib.groovy 文件和一個相應的 DateTagLibTests.groovy 文件:
清單 13. 創建一個新的 TagLib
$ grails create-tag-lib Date
[copy] Copying 1 file to /src/trip-planner2/grails-app/taglib
Created TagLib for Date
[copy] Copying 1 file to /src/trip-planner2/test/integration
Created TagLibTests for Date
將清單 14 中的代碼添加到 DateTagLib.groovy:
清單 14. 創建定制的 TagLib
import java.text.SimpleDateFormat
class DateTagLib {
def customDateFormat = {attrs, body ->
def b = attrs.body ?: body()
def d = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(b)
//if no format attribute is supplied, use this
def pattern = attrs["format"] ?: "MM/dd/yyyy"
out << new SimpleDateFormat(pattern).format(d)
}
}
TagLib 接受屬性形式的簡單的 String 值和標記體,並將一個 String 發送到輸出流。由於您將使用 這個定制標記封裝未格式化的 Date 字段,所以需要兩個 SimpleDateFormat 對象。輸入對象讀入一個與 Date.toString() 調用的默認格式相匹配的 String。當將其解析為適當的 Date 對象之後,您就可以創 建第二個 SimpleDateFormat 對象,以便以另一種格式的 String 將它傳回。
使用新的 TagLib 在 list.gsp 和 show.gsp 中封裝 checkIn 和 checkOut 字段。如清單 15 所示:
清單 15. 使用定制的 TagLib
<g:customDateFormat>${fieldValue(bean:hotelStay, field:'checkIn')} </g:customDateFormat>
輸入 grails run-app,然後訪問 http://localhost:9090/trip/hotelStay/list,檢查實際使用中的 定制 TagLib,如圖 13 所示:
圖 13. 使用定制 TagLib 的數據輸出
現在,編寫清單 16 中的幾個測試,用來檢查 TagLib 是否按照預期工作:
清單 16. 測試定制的 TagLib
import java.text.SimpleDateFormat
class DateTagLibTests extends GroovyTestCase {
void testNoFormat() {
def output =
new DateTagLib().customDateFormat(format:null, body:"2008-10-01 00:00:00.0")
println "\ncustomDateFormat using the default format:"
println output
assertEquals "was the default format used?", "10/01/2008", output
}
void testCustomFormat() {
def output =
new DateTagLib().customDateFormat(format:"EEEE", body:"2008-10-01 00:00:00.0")
assertEquals "was the custom format used?", "Wednesday", output
}
}
結束語
到目前為止,您已經編寫了幾個測試,並看到了用它們測試 Grails 組件是多麼簡單!但是您可以繼 續開拓,不斷取得進步,這會讓您對工作更加自信。將自己的測試和用戶場景匹配起來有這樣的好處:您 將擁有一組永遠保持最新的可執行文檔。
在下一篇文章中,我將重點討論 JavaScript Object Notation (JSON)。Grails 具有出色的開箱即用 的 JSON 支持。您將了解如何通過控制器生成 JSON,以及如何在 GSP 中使用它。在此期間,享受精通 Grails 帶來的樂趣吧。
關於作者
Scott Davis 是國際知名作家、演講家和軟件開發人員。他出版的書籍包括 Groovy Recipes: Greasing the Wheels of Java、GIS for Web Developers: Adding Where to Your Application、The Google Maps API 和 JBoss At Work。