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

精通Grails - 使用Grails進行單元測試(單元測試提速)

編輯:關於JAVA

在本期精通Grails中,Scott Davis 向您展示如何利用 Grails 中包含的 GrailsUnitTestCase 和 ControllerUnitTestCase 類的內置模擬功能。

Grails 支持兩種基本的測試類型:單元測試和集成測試。兩種測試的語法完 全相同:都被使用相同的斷言編寫為一個 GroovyTestCase。它們之間的區別在 於語義上。單元測試用於在隔離環境下測試類,而集成測試支持在完整的、正在 運行的環境中測試類。

該文章是根據當時最新的 Grails 1.0 版本編寫的,在該版本中,測試基礎 架構的功能得到了顯著改進。GrailsUnitTestCase 類及其子類的引入將流程測 試的簡單性和全面性提升到了一個全新的水平。具體來講,這些新測試類的模擬 功能提升了單元測試的速度,同時能夠像在集成測試中一樣正常測試功能。圖 1 展示了 Grails 1.1.x 中全新的測試層次結構:

圖 1. Grails 1.1.x 中全新的測試層次結構

當您在下一節中創建一個新的域類和控制器時,您將了解如何實際應用 GrailsUnitTestCase 和 ControllerUnitTestCase。

開始

要執行本文中的示例,首先創建一個新應用程序。在命令提示符下鍵入:

grails create-app testing

更改到測試目錄(cd testing),然後鍵入:

grails create-domain-class User

接下來鍵入:

grails create-controller User

將清單 1 中的代碼添加到 grails-app/domain/User.groovy 中:

清單 1. User 域類

class User {
  String name
  String login
  String password
  String role = "user"

  static constraints = {
   name(blank:false)
   login(unique:true, blank:false)
   password(password:true, minSize:5)
   role(inList:["user", "admin"])
  }

  String toString(){
   "${name} (${role})"
  }
}

定義 grails-app/controller/UserController.groovy 的核心行為,如清單 2 所示:

清單 2. UserController 類

class UserController {
   def scaffold = true
}

現在基本的基礎架構已經就緒了,接下來添加一些測試。

在 GrailsUnitTestCase 中進行模擬

在文本編輯器中打開 test/unit/UserTests.groovy。代碼如清單 3 所示:

清單 3. UserTests 類

import grails.test.*

class UserTests extends GrailsUnitTestCase {
   protected void setUp() {
     super.setUp()
   }

   protected void tearDown() {
     super.tearDown()
   }

   void testSomething() {

   }
}

在 Grails 1.0 中,create-domain-class 命令創建的存根測試擴展了 GroovyTestCase。可以看到,現在對一個域類的單元測試(在 Grails 1.1 中) 擴展了 GrailsUnitTestCase。所以,您可以使用一些新方法來在單元測試中啟 用模擬功能,這種功能在以前需要在集成測試中啟用。

具體來講,GrailsUnitTestCase 提供了以下模擬方法:

mockForConstraintsTests()

mockDomain()

mockLogging()

要理解這些模擬方法有何用途,首先創建一個會失敗的測試。將 testSomething() 方法更改為 testBlank() 方法,如清單 4 所示:

清單 4. 一個將會失敗的測試

void testBlank() {
  def user = new User()
  assertFalse user.validate()
}

您可能會問這個測試為什麼會失敗,畢竟它的語法是正確的。答案是您現在 運行的是單元測試。單元測試意味著在隔離環境中運行,所以不會運行數據庫和 Web 服務器,最重要的是不會發生與 Grails 相關的元編程。

回頭看一下 清單 1 中 User 域類的源代碼,很明顯其中沒有定義任何 validate() 方法。此方法(以及 save()、list()、hasErrors() 和您熟悉的所 有其他 Groovy Object Relational Mapping (GORM) 方法)都會被 Grails 在 運行時動態添加到域類中。

要運行這個將會失敗的測試,在命令提示符處鍵入 grails test-app。您應 該看到清單 5 所示的結果:

清單 5. 控制台輸出中顯示的失敗測試

$ grails test-app
Environment set to test

Starting unit tests ...
Running tests of type 'unit'
-------------------------------------------------------
Running 2 unit tests...
Running test UserControllerTests...PASSED
Running test UserTests...
           testBlank...FAILED
Tests Completed in 1434ms ...
-------------------------------------------------------
Tests passed: 1
Tests failed: 1
-------------------------------------------------------

Starting integration tests ...
Running tests of type 'integration'
No tests found in test/integration to execute ...

Tests FAILED - view reports in  /testing/test/reports.

在查看失敗報告之前,您是否注意到單元測試運行速度很快,而在運行集成 測試時會有明顯的延遲?鍵入 grails test-app -unit 運行單元測試。即使測 試仍然失敗了,您也應該會看到測試運行速度上的顯著改進。

當然,您可以鍵入 grails test-app -integration 來僅運行集成測試。事 實上,您甚至可以將具有單元和集成標志與測試類的名稱組合在一起。鍵入 grails test-app -unit User 定位到您感興趣的特定測試類。(注意,您在名 稱後面省略了 Tests 後綴,能鍵入更少的內容始終是一件好事)。在現實世界 中,將測試限制到單個類的能力能夠使您對編寫測試充滿信心。

知道您擁有一個失敗的測試之後,您可能希望查看錯誤消息。在 Web 浏覽器 中打開 test/reports/html/index.html。單擊失敗的測試類。將會看到如圖 2 所示的結果:

圖 2. 報告顯示了失敗的單元測試

No signature of method: User.validate() 錯誤消息證實,Grails 確實沒 有將 validate() 方法元編程到 User 類上。

現在,您擁有兩個選擇。第一個選擇是將此測試類轉移到集成目錄中。但是 Grails 轉向運行集成測試需要很長時間,所以此選擇不太理想。第二個選擇是 模擬驗證行為並將測試類保留在單元目錄中。

理解 mockForConstraintsTests()

要在單元測試中模擬 Grails 驗證,添加 mockForConstraintsTests() 方法 ,如清單 6 所示。此方法指示 Grails 將驗證方法元編程到指定的域類上,就 像通常在運行時所做的一樣。

清單 6. 將會通過的測試,這得益於 mockForConstraintsTests()

void testBlank() {
  mockForConstraintsTests(User)
  def user = new User()
  assertFalse user.validate()
}

現在,運行測試來驗證它是否會通過,如清單 7 所示:

清單 7. 運行將會通過的測試

$ grails test-app -unit  User
Environment set to test

Starting unit tests ...
Running tests of type 'unit'
-------------------------------------------------------
Running 1 unit test...
Running test UserTests...PASSED
Tests Completed in 635ms ...
-------------------------------------------------------
Tests passed: 1
Tests failed: 0
-------------------------------------------------------

Tests PASSED - view reports in  /testing/test/reports.

要進一步細化單元測試,您可以斷言驗證會因為特定字段上的特定約束而失 敗,如清單 8 所示。mockForConstraintsTests() 方法將 errors 集合元編程 到域類上。此 errors 集合簡化了對是否觸發了正確的約束的驗證。

清單 8. 斷言特定字段上的一個特定約束違規

void testBlank()  {
  mockForConstraintsTests(User)
  def user = new User()
  assertFalse user.validate()

  println "=" * 20
  println "Total number of errors:"
  println user.errors.errorCount

  println "=" * 20
  println "Here are all of the errors:"
  println user.errors

  println "=" * 20
  println "Here are the errors individually:"
  user.errors.allErrors.each{
   println it
   println "-" * 20
  }

  assertEquals "blank", user.errors["name"]
}

重新運行此測試。它還會意外地失敗嗎?查看報告輸入(如圖 3 所示),找 出問題根源:

圖 3. 用空值替代空格導致的失敗

錯誤消息為 expected:<[blank]> but was:<[nullable]>。驗 證失敗了,但原因並不是您所期望的那樣。

很容易遇到這種錯誤。在 Grails 中,默認情況下,域類中的所有字段必須 非空。這項隱含限制的問題在於,您通常會通過 HTML 表單與 Grails 交互。如 果在 HTML 表單中將 String 字段保留為空,paramsMap 中的控制器會將其看作 空 String(也就是 ""),而不是 null。

如果單擊 HTML 報告底部的 System.out 鏈接,可以看到 3 個 String 字段 (name、login 和 password)都拋出了 nullable 約束違規錯誤。圖 4 顯示了 println 調用的輸出。只有 role 字段 — 其默認值為 user — 通過隱含的 nullable 約束。

圖 4. 測試的 System.out 輸出

再次調整 testBlank() 測試,確保驗證因合適的原因而失敗(從而使單元測 試通過),如清單 9 所示:

清單 9. 測試現在因正確的原因得以通過

void testBlank()  {
  mockForConstraintsTests(User)
  def user = new User(name:"",
            login:"admin",
            password:"wordpass")
  assertFalse user.validate()
  assertEquals 1, user.errors.errorCount
  assertEquals "blank", user.errors["name"]
}

在重新運行測試以確保其通過時,可以解決一個稍微棘手一些的約束: unique。

使用 mockForConstraintsTests() 測試 unique 約束

在上一節已經看到,可以在隔離環境中輕松執行對他多數約束的測試。例如 ,測試 password 字段的 minSize 至少為 5 非常簡單,因為它只依賴於字段本 身的值。清單 10 給出了 testPassword() 方法:

清單 10. 測試 minSize 約束

void testPassword() {
  mockForConstraintsTests(User)
  def user = new User(password:"foo")
  assertFalse user.validate()
  assertEquals "minSize", user.errors["password"]
}

但是如何測試 unique 這樣的約束呢?這種約束確保數據庫表不包含重復值 。幸運的是,mockForConstraintsTests() 還接受第二個參數:一個用於模擬真 實數據庫表的域類列表(替代真實的數據庫表)。清單 11 演示了使用模擬表測 試 unique 約束的過程:

清單 11. 使用模擬表測試 unique 約束

void testUniqueLogin (){
  def jdoe = new User(name:"John Doe",
            login:"jdoe",
            password:"password")

  def suziq = new User(name:"Suzi Q",
             login:"suziq",
             password:"wordpass")

  mockForConstraintsTests(User, [jdoe, suziq])

  def jane = new User(login:"jdoe")
  assertFalse jane.validate()
  assertEquals "unique", jane.errors["login"]
}

在內存中模擬數據庫表可以節省大量時間,尤其是在啟動實際數據庫需要很 長時間時。更糟的是,一旦數據庫開始運行,您仍然需要確保使用使您的斷言得 以通過所必需的記錄來填充數據庫表。

我並不是暗示運行對生產數據庫運行實際的集成測試時浪費時間。我的意思 是,這些耗時的集成測試更適合於持續集成服務器。在這種情況下,模擬數據庫 交互可以使您專注於 Grails 功能,只花少部分時間來進行測試。

模擬數據庫表已超出了 mockForConstraintsTests() 方法的能力范圍。您可 以使用 mockDomain() 方法完成這件事。

理解 mockDomain()

GORM 將一些有用的方法元編程到域類上: save()、list() 和許多定位程序 ,比如 findAllByRole()。顧名思義,mockForConstraintsTests() 方法將驗證 方法添加到域類上,以進行測試。mockDomain() 方法將持久性方法添加到域類 上,以進行測試。清單 12 展示了 mockDomain() 方法的實際應用:

清單 12. 使用 mockDomain() 測試 GORM 方法

void  testMockDomain(){
  def jdoe = new User(name:"John Doe", role:"user")
  def suziq = new User(name:"Suzi Q", role:"admin")
  def jsmith = new User(name:"Jane Smith", role:"user")

  mockDomain(User, [jdoe, suziq, jsmith])

  //dynamic finder
  def list = User.findAllByRole("admin")
  assertEquals 1, list.size()

  //NOTE: criteria, Hibernate Query Language (HQL)
  //   and Query By Example (QBE) are not supported
}

mockDomain() 方法盡可能忠實地建模 GORM 行為。例如,當您將一個域類保 存到模擬表在中時,會像在實際應用程序中一樣填充 id 字段。id 值只是列表 中元素的序數值。清單 13 展示了在單元測試中保存 域類:

清單 13. 將一個域類保存到單元測試中

void testMockGorm() {
  def jdoe = new User(name:"John Doe", role:"user")
  def suziq = new User(name:"Suzi Q", role:"admin")
  def jsmith = new User(name:"Jane Smith", role:"user")

  mockDomain(User, [jdoe, suziq, jsmith])

  def foo = new User(login:"foo")
  foo.name = "Bubba"
  foo.role = "user"
  foo.password = "password"
  foo.save()
  assertEquals 4, foo.id //NOTE: id gets assigned
  assertEquals 3, User.findAllByRole("user").size()
}

模擬底層數據庫並不是您唯一可以在 GrailsUnitTestCase 中完成的工作。 您也可以模擬日志基礎架構。

理解

mockLogging()

GrailsUnitTestCase

的用途並不僅僅是測試域類。鍵入 grails create-service Admin 創建一個 Admin 服務,如清單 14 所示:

模擬和元編程的局限性

mockDomain() 方法只是簡單地利用底層 Groovy 語言的本機動態功能。(要 了解 Groovy 中的元編程的更多信息,請查閱 “實戰 Groovy:使用閉包、 ExpandoMetaClass 和類別進行元編程。”)實際上,它會攔截通常存在於域類 上的方法調用,並將它們替換為模擬行為,以進行測試。毫無疑問,這意味著不 會模擬其他支持技術,比如條件塊、 Hibernate Query Language (HQL) 和 Query By Example (QBE)。如果您的代碼依賴於這些技術中的任何一種,您需要 編寫集成測試並運行一個實際的數據庫。

清單 14. 創建服務

$ grails create-service Admin

Created Service for Admin
Created Tests for Admin

毫無疑問,AdminService.groovy 文件會出現在 grails-app/services 目錄 中。如果查看 test/unit 目錄,應該會看到一個名為 AdminServiceTests.groovy 的 GrailsUnitTestCase。

向 AdminService 添加一個假設性方法,僅允許 admin 角色中的用戶重啟服 務器,如清單 15 所示:

清單 15. 將 restart() 方法添加到 AdminService

class  AdminService {
  boolean transactional = true

  def restartServer(User user) {
   if(user.role == "admin"){
    //restart the server
    return true
   }else{
    log.info "Ha! ${user.name} thinks s/he is an  admin..."
    return false
   }
  }
}

對此服務的測試非常簡單。將 testRestartServer() 方法添加到 test/unit/AdminServiceTests.groovy,如清單 16 所示:

清單 16. 一個將會失敗的服務測試

void testRestartServer()  {
  def jdoe = new User(name:"John Doe", role:"user")
  def suziq = new User(name:"Suzi Q", role:"admin")

  //NOTE: no DI in unit tests
  def adminService = new AdminService()
  assertTrue adminService.restartServer(suziq)
  assertFalse adminService.restartServer(jdoe)
}

當在命令提示符處鍵入 grails test-app -unit AdminService 來運行此測 試時,將會失敗。就像最初的 User 測試運行一樣,導致它失敗的原因並不是您 所期望的那樣。看一下 HTML 報告,會發現熟悉的 No such property: log for class: AdminService 消息,如圖 5 所示:

圖 5. 依賴性注入的缺乏導致了單元測試失敗

但是,這次失敗並不是因為域類上缺少元編程,而是因為缺少依賴性注入。 具體來講,所有 Grails 工件都會在運行時被注入一個 log 對象,以便它們可 以輕松地記錄消息,以供未來審核。

要注入一個模擬日志記錄程序以供測試,將 AdminService 類封裝到一個 mockLogging() 方法調用中,如清單 17 所示:

清單 17. 此測試將通過,這得益於 mockLogging()

void  testRestartServer() {
  def jdoe = new User(name:"John Doe", role:"user")
  def suziq = new User(name:"Suzi Q", role:"admin")

  mockLogging(AdminService)
  def adminService = new AdminService()
  assertTrue adminService.restartServer(suziq)
  assertFalse adminService.restartServer(jdoe)
}

這一次,與預期的一樣,測試通過了。所有日志輸出都被發送到 System.out 。請記住,您可以在 HTML 報告中看到此輸出。

理解 ControllerUnitTestCase

使用 GrailsUnitTestCase,可以輕松測試域類和服務,但測試控制器還需要 其他一些功能。ControllerUnitTestCase 擴展了 GrailsUnitTestCase,所以您 仍然可以像以前一樣使用 mockForConstraintsTests()、mockDomain() 和 mockLogging()。而且 ControllerUnitTestCase 為您正在測試的控制器創建一 個新實例,並將其存儲在名為 controller 的變量中。這個 controller 變量可 用於在測試期間以編程方式與控制器交互。

要更好地理解核心控制器的功能,在命令提示符處鍵入 grails generate- controller User。這將 def scaffold = true 替換為控制器代碼的完全實現。

在完全實現的 grails-app/controllers/UserController.groovy 文件中, 您可以看到,調用 index 操作會重定向到 list 操作,如清單 18 所示:

清單 18. UserController 中默認的 index 操作

class  UserController {

   def index = { redirect(action:list,params:params) }

}

要驗證是否按預期發生了重定向,將一個 testIndex() 方法添加到 test/unit/UserControllerTests.groovy,如清單 19 所示:

清單 19. 測試默認的 index 操作

import  grails.test.*

class UserControllerTests extends ControllerUnitTestCase {
   void testIndex() {
    controller.index()
    assertEquals controller.list, controller.redirectArgs ["action"]
   }
}

可以看到,您首先調用控制器操作,就像它是另一個控制器上的方法一樣。 redirect 參數存儲一個名為 redirectArgs 的 Map 中。斷言驗證 action 鍵是 否包含 list 值。(如果操作以一個 render 結束,那麼您可以根據名為 renderArgs 的 Map 進行斷言)。

現在假設 index 操作稍微先進一些。它檢查一個 User 的會話並根據用戶是 否為 admin 來重定向會話。在 ControllerUnitTestCase 中,session 和 flash 都是 Map,您可以在調用或調用之後的斷言之前對它們進行填充。更改 index 操作,如清單 20 所示:

清單 20. 更加先進的 index 操作

def index = {
  if(session?.user?.role == "admin"){
   redirect(action:list,params:params)
  }else{
   flash.message = "Sorry, you are not authorized to view  this list."
   redirect(controller:"home", action:index)
  }
}

要測試這項新功能,更改 UserControllerTests.groovy 中的 testIndex() 方法,如清單 21 所示:

清單 21. 測試 session 和 flash 值

void testIndex() {
  def jdoe = new User(name:"John Doe", role:"user")
  def suziq = new User(name:"Suzi Q", role:"admin")

  controller.session.user = jdoe
  controller.index()
  assertEquals "home", controller.redirectArgs["controller"]
  assertTrue controller.flash.message.startsWith("Sorry")

  controller.session.user = suziq 
  controller.index()
  assertEquals controller.list, controller.redirectArgs ["action"]
}

一些控制器操作需要傳入參數。在 ControllerUnitTestCase 中,您可以將 值添加到 params Map 中,就像將值添加到 flash 和 session 一樣。清單 22 給出了默認的 show 操作:

清單 22. 默認的 show 操作

def show = {
   def userInstance = User.get( params.id )

   if(!userInstance) {
     flash.message = "User not found with id  ${params.id}"
     redirect(action:list)
   }
   else { return [ userInstance : userInstance ] }
}

還記得 GrailsUnitTestCase 的 mockDomain() 方法嗎?您可以在這裡使用 它來模擬 User 表,如清單 23 所示:

清單 23. 測試默認的 show 操作

void testShow() {
  def jdoe = new User(name:"John Doe",
            login:"jdoe",
            password:"password",
            role:"user")

  def suziq = new User(name:"Suzi Q",
            login:"suziq",
            password:"wordpass",
            role:"admin")

  mockDomain(User, [jdoe, suziq])

  controller.params.id = 2 

  // this is the HashMap returned by the show action
  def returnMap = controller.show()
  assertEquals "Suzi Q", returnMap.userInstance.name
}

使用 ControllerUnitTestCase 測試 RESTful Web 服務

有時,要測試控制器,您需要訪問原始的請求和響應。對於 ControllerUnitTestCase,您可以分別通過 controller.request 和 controller.response 對象獲取以下信息: GrailsMockHttpServletRequest 和 GrailsMockHttpServletResponse。

您可以查閱 “精通 Grails:RESTful Grails” 獲取設置 RESTful 服務的 指南。再結合 “實戰 Groovy:構建和解析 XML” 分析結果,您就具備了測試 RESTful Web 服務所需的一切了。

將一個簡單的 listXml 操作添加到 UserController,如清單 14 所示。( 不要忘記導入 grails.converters 包)。

清單 24. 控制器中的簡單 XML 輸出

import  grails.converters.*
class UserController {
  def listXml = {
   render User.list() as XML
  }

  // snip...
}

然後將一個 testListXml() 方法添加到 UserControllerTests.groovy,如 清單 25 所示:

清單 25. 測試 XML 輸出

void testListXml() {

  def suziq = new User(name:"Suzi Q",
            login:"suziq",
            password:"wordpass",
            role:"admin")

  mockDomain(User, [suziq])

  controller.listXml()
  def xml = controller.response.contentAsString
  def list = new XmlParser().parseText(xml)
  assertEquals "suziq", list.user.login.text()

  //output
  /*
  <?xml version="1.0" encoding="UTF-8"?>
  <list>
   <user>
    <class>User</class>
    <id>1</id>
    <login>suziq</login>
    <name>Suzi Q</name>
    <password>wordpass</password>
    <role>admin</role>
    <version />
   </user>
  </list>
  */
}

此測試中發生的第一件事是,創建一個新 User 並將其存儲在 suziq 變量中 ,接下來,模擬 User 表,將 suziq 存儲為唯一的記錄。

當基本設置完成之後,調用 listXml() 操作。要以 String 的形式從操作獲 取生成的 XML,調用 controller.response.contentAsString 並將其存儲在 xml 變量中。

現在,您擁有了一個原始 String。(此 String 的內容僅用於在方法末尾的 output 注釋中引用)。調用 new XmlParser().parseText(xml) 會以 groovy.util.Node 對象的形式返回根元素 (<list>)。一旦擁有了 XML 文檔的根節點,您就可以使用 GPath 表達式(例如 list.user.login.text()) 來斷言,<login> 元素包含預期的值(在本例中為 suziq)。

可以看到,Grails converters 包簡化了 XML 的生成過程,本機 Groovy 庫 XmlParser 簡化了 XML 的解析過程,而 ControllerUnitTestCase 簡化了測試 結果 GrailsMockHttpServletResponse 的過程。這是一個強大的技術組合,使 得只需短短幾行代碼就可以進行測試。

結束語

在本文中,您學習了內置的測試類 GrailsUnitTestCase 和 ControllerUnitTestCase,它們大大簡化了 Grails 應用程序的測試。 mockForConstraintsTests()、mockDomain() 和 mockLogging() 方法支持編寫 更快的單元測試來代替緩慢的集成測試,從而顯著提高應用程序開發速度。

在下一期中,我將介紹社區提供的一些測試插件,這些插件能夠簡化集成測 試。屆時請繼續享受精通 Grails 帶來的樂趣吧。

本文配套源碼

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