使用 Grails 进行单元测试

红薯 发布于 2009/11/24 18:00
阅读 1K+
收藏 0

在本文中,您将学习如何利用 GrailsUnitTestCaseControllerUnitTestCase 的模拟功能轻松对 Grails 工件进行单元测试,没有这项功能,您将需要进行集成测试。mockForConstraintsTests()mockDomain()mockLogging() 方法都利用了 Groovy 的元编程功能,使对域类、服务和控制器的测试变得出奇的简单。

在 “精通 Grails:测试 Grails 应用程序” 中,您已对单元测试和集成测试有所了解:

Grails 支持两种基本的测试类型:单元测试和集成测试。两种测试的语法完全相同:都被使用相同的断言编写为一个 GroovyTestCase。它们之间的区别在于语义上。单元测试用于在隔离环境下测试类,而集成测试支持在完整的、正在运行的环境中测试类。

该文章是根据当时最新的 Grails 1.0 版本编写的,在该版本中,测试基础架构的功能得到了显著改进。GrailsUnitTestCase 类及其子类的引入将流程测试的简单性和全面性提升到了一个全新的水平。具体来讲,这些新测试类的模拟功能提升了单元测试的速度,同时能够像在集成测试中一样正常测试功能。图 1 展示了 Grails 1.1.x 中全新的测试层次结构:


图 1. Grails 1.1.x 中全新的测试层次结构
图 1. Grails 1.1.x 中全新的测试层次结构

当您在下一节中创建一个新的域类和控制器时,您将了解如何实际应用 GrailsUnitTestCaseControllerUnitTestCase。(本文示例的完整源代码可 下载 获得)。

开始

要执行本文中的示例,首先创建一个新应用程序。在命令提示符下键入:

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 相关的元编程。

回头看一下 清单 1User 域类的源代码,很明显其中没有定义任何 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. 报告显示了失败的单元测试
图 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. 用空值替代空格导致的失败
图 3. 用空值替代空格导致的失败

错误消息为 expected:<[blank]> but was:<[nullable]>。验证失败了,但原因并不是您所期望的那样。

很容易遇到这种错误。在 Grails 中,默认情况下,域类中的所有字段必须非空。这项隐含限制的问题在于,您通常会通过 HTML 表单与 Grails 交互。如果在 HTML 表单中将 String 字段保留为空,params Map 中的控制器会将其看作空 String(也就是 ""),而不是 null

如果单击 HTML 报告底部的 System.out 链接,可以看到 3 个 String 字段(nameloginpassword)都抛出了 nullable 约束违规错误。图 4 显示了 println 调用的输出。只有 role 字段 — 其默认值为 user — 通过隐含的 nullable 约束。


图 4. 测试的 System.out 输出
图 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 所示:

清单 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. 依赖性注入的缺乏导致了单元测试失败
图 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 参数存储一个名为 redirectArgsMap 中。断言验证 action 键是否包含 list 值。(如果操作以一个 render 结束,那么您可以根据名为 renderArgsMap 进行断言)。

现在假设 index 操作稍微先进一些。它检查一个 User 的会话并根据用户是否为 admin 来重定向会话。在 ControllerUnitTestCase 中,sessionflash 都是 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. 测试 sessionflash

				
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 中,就像将值添加到 flashsession 一样。清单 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 ] }
}

 

还记得 GrailsUnitTestCasemockDomain() 方法吗?您可以在这里使用它来模拟 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.requestcontroller.response 对象获取以下信息: GrailsMockHttpServletRequestGrailsMockHttpServletResponse

您可以查阅 “精通 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 的过程。这是一个强大的技术组合,使得只需短短几行代码就可以进行测试。

结束语

在本文中,您学习了内置的测试类 GrailsUnitTestCaseControllerUnitTestCase,它们大大简化了 Grails 应用程序的测试。mockForConstraintsTests()mockDomain()mockLogging() 方法支持编写更快的单元测试来代替缓慢的集成测试,从而显著提高应用程序开发速度。

在下一期中,我将介绍社区提供的一些测试插件,这些插件能够简化集成测试。届时请继续享受精通 Grails 带来的乐趣吧。

下载本文中的代码

加载中
返回顶部
顶部