程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> 精通Grails: 測試 Grails 應用程序

精通Grails: 測試 Grails 應用程序

編輯:關於JAVA

我是測試驅動開發(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。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved