精通 Grails: 测试 Grails 应用程序

bee00 2013-11-18

排除bug,构建可执行文档

Grails可以轻松确保您的应用程序从始至终都远离bug。这还有另一个好处,您可以利用测试代码生成一组通常是最新的可执行文档。本月Grails专家ScottDavis向您展示如何对Grails进行测试。

查看本系列更多内容|评论:

ScottDavis,主编,AboutGroovy.com

2008年10月31日

+

内容

我是测试驱动开发(test-drivendevelopment,TDD)的大力支持者。NealFord(TheProductiveProgrammer的作者)说道“不测试所编写的代码就是失职”。MichaelFeathers(WorkingEffectivelywithLegacyCode的作者)将“遗留代码”定义为没有经过相应测试的任何软件—这表明编写代码而不进行测试是一种过时的实践。我常说每编写一定数量的生产代码,就要编写两倍的测试代码。

精通Grails尚未讨论TDD,因为到目前为止,这个系列主要关注如何利用Grails的核心功能。测试基础设施代码(不用您编写的代码)有一定的价值,但我很少这样做。我相信Grails能够正确地将我的POGO呈现为XML,或在我调用trip.save()时将我的Trip保存到数据库。当您检查自己编写的代码时,测试的真正价值就体现出来了。如果您编写一个复杂的算法,您应该有一个或多个补充单元测试,确保该算法正常工作。在本文,您将看到Grails如何帮助和鼓励您进行应用程序测试。

编写第一个测试

在开始测试之前,我将介绍一个新的域类。这个类的一些定制功能必须经过测试才能进入到生产中。输入grailscreate-domain-classHotelStay,如清单1所示:

关于本系列

Grails是一种新型Web开发框架,它将常见的Spring和Hibernate等Java™技术与当前流行的约定优于配置等实践相结合。Grails是用Groovy编写的,它可以提供与遗留Java代码的无缝集成,同时还可以加入脚本编制语言的灵活性和动态性。学习完Grails之后,您将彻底改变看待Web开发的方式。

清单1.创建HotelStay类

$grailscreate-domain-classHotelStay

Environmentsettodevelopment

[copy]Copying1fileto/src/trip-planner2/grails-app/domain

CreatedDomainClassforHotelStay

[copy]Copying1fileto/src/trip-planner2/test/integration

CreatedTestsforHotelStay

从清单1可以看到,Grails在grails-app/domain目录中为您创建了一个空的域类。它还在test/integration目录中创建了一个带有空的testSomething()方法的GroovyTestCase类(稍后我将进一步讲述单元测试和集成测试的区别)。清单2展示了一个带有生成的测试的空HotelStay类:

清单2.带有生成的测试的空类

classHotelStay{

}

classHotelStayTestsextendsGroovyTestCase{

voidtestSomething(){

}

}

GroovyTestCase是在JUnit3.x单元测试之上的一层Groovy。如果您熟悉JUnitTestCase,您肯定知道GroovyTestCase是如何工作的。对于这两种情况,您通过断言代码正常工作来测试它们。JUnit有各种不同的断言方法,包括assertEquals、assertTrue和assertNull等等。它使您通过编程的方式表明“我断言这个代码按照预期工作”。

为什么是JUnit3.x而不是4.x?

由于历史原因,GroovyTestCase就是一个JUnit3.xTestCase。当Groovy1.0于2007年1月发布时,它支持Java1.4语言结构。它可以在Java1.4、1.5和1.6JVM上运行,但在语言级别上仅与Java1.4兼容。

接下来Groovy的主要发布版是1.5,在2008年1月发布。Groovy1.5支持所有Java1.5语言特性,比如泛型、静态导入、for/in循环和注释(后者最值得讨论)。不过Groovy1.5仍然可以在Java1.4JVM上运行。Groovy开发团队许诺所有Groovy1.x版本都将与Java1.4保持向后兼容性。当Groovy2.x发布时(可能是2009年末或2010年),它将不支持Java1.4。

因此,这些与GroovyTestCase打包的JUnit版本有什么关系呢?JUnit4.x引入了一些注释,比如@test、@before和@after。尽管这些新特性非常有趣,但JUnit3.x仍然是GroovyTestCase向后兼容Java1.4的基础。

这就是说,您完全可以使用JUnit4.x(参见参考资料获得Groovy站点相关文档的链接)。引入其他使用注释和Java5语言特性的测试框架是完全有可能的(参见参考资料获得结合使用TestNG和Groovy的示例)。Groovy的字节码与Java编程兼容,因此您可以通过Groovy使用任何Java测试框架。

将清单3中的代码添加到grails-app/domain/HotelStay.groovy和test/integration/HotelStayTests.groovy:

清单3.一个简单的测试

classHotelStay{

Stringhotel

}

classHotelStayTestsextendsGroovyTestCase{

voidtestSomething(){

HotelStayhs=newHotelStay(hotel:"Sheraton")

assertEquals"Sheraton",hs.hotel

}

}

清单3正是我前面提到那种低级Grails基础设施测试。您应该相信Grails能够正确执行这个操作,因此这是一个典型的错误测试类型。但它允许您编写最简单的测试并观察其运行,实现了本文的目的。

要运行所有测试,请输入grailstest-app。要仅运行这个测试,请输入grailstest-appHotelStay(由于约定优于配置,Tests后缀可以省略)。不管输入哪个命令,您应该会在命令提示中看到如清单4所示的输出(注意:为了突出重要的特性,我删减了许多代码)。

清单4.运行测试时的输出

$grailstest-app

Environmentsettotest

Notestsfoundintest/unittoexecute...

-------------------------------------------------------

Running1IntegrationTest...

RunningtestHotelStayTests...

testSomething...SUCCESS

IntegrationTestsCompletedin253ms

-------------------------------------------------------

Testspassed.Viewreportsin/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顶级汇总报告

JUnit顶级汇总报告

如果您单击HotelStayTests链接,应该会看到doSomething()测试,如图2所示:

图2.JUnit类级报告

JUnit类级报告

如果测试意外失败,命令提示输出和HTML报告(如图3所示)将通知您:

图3.失败的JUnit测试

失败的JUnit测试

回页首

编写第一个有价值的测试

以上是第一个正常运行的简单测试,接下来将展示一个更加实用的测试示例。假设您的HotelStay类有两个字段:DatecheckIn和DatecheckOut。根据一个用户情景,toString方法的输出应该像这样:Hilton(WednesdaytoSunday)。通过java.text.SimpleDateFormat类,获取正确格式的日期非常简单。您应该为此编写一个测试,但不需验证SimpleDateFormat是否正确工作。您的测试做两件事情:它验证toString方法是否按照预期运行;它证明您是否满足用户情景。

单元测试是可执行的文档

用户需求常常是桌面上的某些文档。作为开发人员,您应该将这些需求转换成有效的软件。

需求文档的问题是:在进行实际软件开发时它通常已经过时。它不是可以随着软件的发展而变化的“活动文档”。工件一词完美地描述了这种情况—文档描述软件最初的、历史性的任务是什么,而不是当前实现要做什么。

要想准备一组全面的、优秀的测试,仅仅保持代码没有bug是不够的。这样的测试有一个附带的好处,即您可以得到“可执行的文档”:用代码表示活动的、不断变化的项目需求。如果将测试映射到需求,则可以和用户共享某些内容。您必须保证代码的健全,保证满足了用户的需求。将这个可执行文档与CruiseControl等持续集成服务器(持续反复地运行测试的服务器)相结合,就可以得到一个安全保障机制,它保证新特性不会对原本良好的软件造成损害。

行为驱动的开发(Behavior-DrivenDevelopment,BDD)完全采用了可执行文档的想法。easyb是一个用Groovy编写的BDD,它允许您将测试编写成用户和开发人员都可以阅读的用户需求(参见参考资料)。如果一些用户思想比较前卫,宁愿放弃Microsoft®Word(例如),easyb可以排除所有过时的需求文档。因此,项目需求从一开始就是可执行的。

将清单5中的代码输入到HotelStay.groovy和HotelStayTests.groovy:

清单5.使用assertToString

importjava.text.SimpleDateFormat

classHotelStay{

Stringhotel

DatecheckIn

DatecheckOut

StringtoString(){

defsdf=newSimpleDateFormat("EEEE")

"${hotel}(${sdf.format(checkIn)}to${sdf.format(checkOut)})"

}

}

importjava.text.SimpleDateFormat

classHotelStayTestsextendsGroovyTestCase{

voidtestSomething(){...}

voidtestToString(){

defh=newHotelStay(hotel:"Hilton")

defdf=newSimpleDateFormat("MM/dd/yyyy")

h.checkIn=df.parse("10/1/2008")

h.checkOut=df.parse("10/5/2008")

printlnh

assertToStringh,"Hilton(WednesdaytoSunday)"

}

}

输入grailstest-app验证第二个测试是否通过。

testToString方法使用了新的断言方法之一—assertToString—它由GroovyTestCase引入。使用JUnitassertEquals方法肯定会获得相同的结果,但是assertToString的表达能力更强。测试方法的名称和最终的断言清楚地表明了这个测试的目的(参见参考资料获得一个链接,它列出了GroovyTestCase支持的所有断言,包括assertArrayEquals、assertContains和assertLength)。

回页首

添加控制器和视图

到目前为止,您一直以编程的方式与HotelStay域类交互。添加一个HotelStayController,如清单6所示,它使您能够在Web浏览器上使用该类:

清单6.HotelStayController源代码

classHotelStayController{

defscaffold=HotelStay

}

您应该对create表单进行仔细的UI调试。默认情况下,日期字段包括day、month、year、hours和minutes,如图4所示:

图4.默认显示日期和时间

默认显示日期和时间

在这里,忽略日期字段的时间戳部分是安全的。输入grailsgenerate-viewsHotelStay。要创建图5所示的经过修改的UI,请将precision="day"添加到views/hotelStay/create.gsp和views/hotelStay/edit.gsp中的<g:datePicker>元素:

图5.仅显示日期

仅显示日期

有了运行在servlet容器中的活动的、有效的HotelStay之后,就要开始讨论测试了:单元测试还是集成测试?

回页首

对比单元测试和集成测试

如我前面所述,Grails支持两种基本类型的测试:单元测试和集成测试。这两者之间没有语法区别—它们都是用相同的断言写的GroovyTestCase。它们的区别在于语义。单元测试孤立地测试类,而集成测试在一个完整的运行环境中测试类。

坦白地说,如果您想将所有的Grails测试都编写成集成测试,则刚好符合我的想法。所有Grailscreate-*命令都生成相应的集成测试,所以很多人都使用现成的集成测试。正如稍后看到的一样,很多测试需要在完整的运行环境中进行,因此默认使用集成测试是很好的选择。

如果您想测试一些非核心Grails类,则适合使用单元测试。要创建一个单元测试,请输入grailscreate-unit-testMyTestUnit。因为测试脚本不是在不同的包中创建的,所以单元测试和集成测试的名称应该是惟一的。如果不是这样的话,将会收到清单7所示的错误消息:

清单7.单元测试和集成测试同名时收到的错误消息

Thesources

/src/trip-planner2/test/integration/HotelStayTests.groovyand

/src/trip-planner2/test/unit/HotelStayTests.groovyare

containingbothaclassofthenameHotelStayTests.

@line3,column1.

classHotelStayTestsextendsGroovyTestCase{

^

1error

因为集成测试默认使用后缀Tests,所以我在所有单元测试上都使用后缀UnitTests,避免混淆。

回页首

为简单的验证错误消息编写测试

下一个用户场景说明hotel字段不能留空。这很容易通过内置的Grails验证框架来实现。将一个staticconstraints块添加到HotelStay,如清单8所示:

清单8.将一个staticconstraints块添加到HotelStay

classHotelStay{

staticconstraints={

hotel(blank:false)

checkIn()

checkOut()

}

Stringhotel

DatecheckIn

DatecheckOut

//therestoftheclassremainsthesame

}

输入grailsrun-app。如果您尝试在留空hotel字段的情况下创建一个HotelStay,将收到如图6所示的错误消息:

图6.空字段的默认错误消息

空字段的默认错误消息

我敢保证您的用户会喜欢这个特性,但对默认的错误消息还不是很满意。假设他们稍微改动了一下用户场景:hotel字段不能留空;如果留空,错误消息会提示“Pleaseprovideahotelname”。

现在您已经添加了一些定制代码—尽管它就像一个定制的String那么简单—接下来应该添加测试了(当然,编写一个验证用户场景的完整性的测试—尽管不涉及到定制代码—也是完全可以接受的。

打开grails-app/i18n/messages.properties并添加hotelStay.hotel.blank=Pleaseprovideahotelname。尝试在浏览器中提交一个空hotel。这时您将看到自己的定制消息,如图7所示:

图7.显示定制的验证错误消息

显示定制的验证错误消息

向HotelStayTests.groovy添加一个新测试,检验对空字段的验证是否有效,如清单9所示:

清单9.测试验证错误

classHotelStayTestsextendsGroovyTestCase{

voidtestBlankHotel(){

defh=newHotelStay(hotel:"")

assertFalse"thereshouldbeerrors",h.validate()

assertTrue"anotherwaytocheckforerrorsafteryoucallvalidate()",h.hasErrors()

}

//therestofthetestsremainunchanged

}

在生成的控制器中,您已经看到添加到域类中的save()方法。在这里,我本来也可以调用save(),但事实上我并不想把新的类保存到数据库。我只关注验证是否发生。由validate()方法来完成这个任务。如果验证失败,则返回false。如验证成功,则返回true。

hasErrors()是另一个很有价值的测试方法。在调用save()或validate()之后,hasErrors()允许您查看验证错误。

清单10是经过扩展的testBlankHotel(),它引入了其他一些很有用的验证方法:

清单10.验证错误的高级测试

classHotelStayTestsextendsGroovyTestCase{

voidtestBlankHotel(){

defh=newHotelStay(hotel:"")

assertFalse"thereshouldbeerrors",h.validate()

assertTrue"anotherwaytocheckforerrorsafteryoucallvalidate()",h.hasErrors()

println"\nErrors:"

printlnh.errors?:"noerrorsfound"

defbadField=h.errors.getFieldError('hotel')

println"\nBadField:"

printlnbadField?:"hotelwasn'tabadfield"

assertNotNull"I'mexpectingtofindanerroronthehotelfield",badField

defcode=badField?.codes.find{it=='hotelStay.hotel.blank'}

println"\nCode:"

printlncode?:"theblankhotelcodewasn'tfound"

assertNotNull"theblankhotelfieldshouldbetheculprit",code

}

}

确定类没有通过验证之后,您可以调用getErrors()方法(在这里,借助Groovy简洁的getter语法,它被缩略为errors),返回一个org.springframework.validation.BeanPropertyBindingResult。就像GORM与Hibernate相比是一个瘦Groovy层一样,Grails验证只不过是一个简单的Spring验证。

调用println的结果不会在命令行上显示,但它们出现在HTML报告中,如图8所示:

图8.查看测试的println输出

查看测试的println输出

在HotelStayTests报告的右下角单击System.out链接。

清单10中给人亲切感觉的Elvis操作符(转过脸来—看见他向后梳起的发型和那双眼睛吗?)是一个缩略的Groovy三元操作符。如果?:左边的对象为null,将使用右边的值。

将hotel字段更改为"HolidayInn"并重新运行测试。您将在HTML报告中看到另一个Elvis输出,如图9所示:

图9.测试输出中的Elvis

测试输出中的Elvis

看见Elvis之后,不要忘记清空hotel字段—如果您不希望留下中断的测试的话。

如果仍然显示关于checkIn和checkOut的验证错误,您不必担心。就这个测试而言,您完全可以忽略它们。但是这表明您不应该仅测试错误是否出现—您应该确保特定的错误被抛出。

注意,我没有断言定制错误消息的确切文本。为什么我上一次关注匹配的字符串(测试toString的输出时)而这一次没有关注?toString方法的定制输出便是上一个测试的目的。这一次,我更关心的是确定验证代码的执行,而不是Grails是否正确呈现消息。这表明测试更像一门艺术,而不是科学(如果我想验证准确的消息输出,则应该使用Web层测试工具,比如CanooWebTest或ThoughtWorksSelenium)。

回页首

创建和测试定制验证

现在,应该处理下一个用户场景了。您需要确保checkOut日期发生在checkIn日期之后。要解决这个问题,您需要编写一个定制验证。编写完之后,要验证它。

将清单11中的定制验证代码添加到staticconstraints块:

清单11.一个定制的验证

classHotelStay{

staticconstraints={

hotel(blank:false)

checkIn()

checkOut(validator:{val,obj->

returnval.after(obj.checkIn)

})

}

//therestoftheclassremainsthesame

}

val变量是当前的字段。obj变量表示当前的HotelStay实例。Groovy将before()和after()方法添加到所有Date对象,所以这个验证仅返回after()方法调用的结果。如果checkOut发生在checkIn之后,验证返回true。否则,它返回false并触发一个错误。

现在,输入grailsrun-app。确保不能创建一个checkOut日期早于checkIn日期的新HotelStay实例。如图10所示:

图10.默认的定制验证错误消息

默认的定制验证错误消息

打开grails-app/i18n/messages.properties,并向checkOut字段添加一个定制验证消息:hotelStay.checkOut.validator.invalid=Sorry,youcannotcheckoutbeforeyoucheckin。

保存messages.properties文件并尝试保存有缺陷的HotelStay。您将看到如清单11所示的错误消息:

清单11.定制验证错误消息

定制验证错误消息

现在应该编写测试了,如清单12所示:

清单12.测试定制的验证

importjava.text.SimpleDateFormat

classHotelStayTestsextendsGroovyTestCase{

voidtestCheckOutIsNotBeforeCheckIn(){

defh=newHotelStay(hotel:"Radisson")

defdf=newSimpleDateFormat("MM/dd/yyyy")

h.checkIn=df.parse("10/15/2008")

h.checkOut=df.parse("10/10/2008")

assertFalse"thereshouldbeerrors",h.validate()

defbadField=h.errors.getFieldError('checkOut')

assertNotNull"I'mexpectingtofindanerroronthecheckOutfield",badField

defcode=badField?.codes.find{it=='hotelStay.checkOut.validator.invalid'}

assertNotNull"thecheckOutfieldshouldbetheculprit",code

}

}

回页首

测试定制的TagLib

接下来是最后一个需要处理的用户场景。您已经在create和edit视图中成功地处理了checkIn和checkOut的时间戳部分,但它在list和show视图中仍然是错误的,如图12所示:

图12.默认的Grails日期输入(包括时间戳)

默认的Grails日期输入(包括时间戳)

最简单的解决办法是定义一个新的TagLib。您可以利用Grails已经定义的<g:formatDate>标记,但创建一个自己的定制标记也很容易。我想创建一个可以以两种方式使用的<g:customDateFormat>标记。

一种形式的<g:customDateFormat>标记打包一个Date,并接受一个接受任何有效SimpleDateFormat模式的定制格式属性:

<g:customDateFormatformat="EEEE">${newDate()}</g:customDateFormat>

因为大多数用例都以美国的“MM/dd/yyyy”格式返回日期,所以如果没有特别指定,我将采用这种格式:

<g:customDateFormat>${newDate()}</g:customDateFormat>

现在,您已经知道了每个用户场景的需求,那么请输入grailscreate-tag-libDate(如清单13所示),以创建一个全新的DateTagLib.groovy文件和一个相应的DateTagLibTests.groovy文件:

清单13.创建一个新的TagLib

$grailscreate-tag-libDate

[copy]Copying1fileto/src/trip-planner2/grails-app/taglib

CreatedTagLibforDate

[copy]Copying1fileto/src/trip-planner2/test/integration

CreatedTagLibTestsforDate

将清单14中的代码添加到DateTagLib.groovy:

清单14.创建定制的TagLib

importjava.text.SimpleDateFormat

classDateTagLib{

defcustomDateFormat={attrs,body->

defb=attrs.body?:body()

defd=newSimpleDateFormat("yyyy-MM-ddhh:mm:ss").parse(b)

//ifnoformatattributeissupplied,usethis

defpattern=attrs["format"]?:"MM/dd/yyyy"

out<<newSimpleDateFormat(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>

输入grailsrun-app,然后访问http://localhost:9090/trip/hotelStay/list,检查实际使用中的定制TagLib,如图13所示:

图13.使用定制TagLib的数据输出

使用定制TagLib的数据输出

现在,编写清单16中的几个测试,用来检查TagLib是否按照预期工作:

清单16.测试定制的TagLib

importjava.text.SimpleDateFormat

classDateTagLibTestsextendsGroovyTestCase{

voidtestNoFormat(){

defoutput=

newDateTagLib().customDateFormat(format:null,body:"2008-10-0100:00:00.0")

println"\ncustomDateFormatusingthedefaultformat:"

printlnoutput

assertEquals"wasthedefaultformatused?","10/01/2008",output

}

voidtestCustomFormat(){

defoutput=

newDateTagLib().customDateFormat(format:"EEEE",body:"2008-10-0100:00:00.0")

assertEquals"wasthecustomformatused?","Wednesday",output

}

}

回页首

结束语

到目前为止,您已经编写了几个测试,并看到了用它们测试Grails组件是多么简单!但是您可以继续开拓,不断取得进步,这会让您对工作更加自信。将自己的测试和用户场景匹配起来有这样的好处:您将拥有一组永远保持最新的可执行文档。

在下一篇文章中,我将重点讨论JavaScriptObjectNotation(JSON)。Grails具有出色的开箱即用的JSON支持。您将了解如何通过控制器生成JSON,以及如何在GSP中使用它。在此期间,享受精通Grails带来的乐趣吧。

相关推荐