Grails 控制器测试
在本指南中,我们探讨了 Grails 中的控制器测试。
作者: Nirav Assar、Sergio del Amo
Grails 版本 3.3.0
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员进行开发和交付!
2 开始使用
在本指南中,您将探讨 Grails 中的控制器测试。我们将重点关注一些最常见用例
单元测试
-
验证允许方法。
-
验证模型、视图、状态和重定向
-
使用存根时,使用服务
-
验证 HTTP 参数绑定到命令对象
-
验证 JSON 负载绑定到命令对象
功能测试
-
JSON 响应、状态代码
2.1 所需内容
完成本指南,您需要以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
安装具有适当配置的
JAVA_HOME
的 JDK 1.8 或更高版本
2.2 如何完成指南
要开始进行,请执行以下操作
-
下载并解压源代码
或
-
克隆 Git 存储库
git clone https://github.com/grails-guides/grails-controller-testing.git
Grails 指南存储库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,具有一些其他代码,以便让您轻松上手。 -
complete
一个已完成的示例。这是完成指南中介绍的步骤并在initial
文件夹中应用这些更改的结果。
要完成该指南,请转到 initial
文件夹
-
cd
转到grails-guides/grails-controller-testing/initial
并按照下一部分中的说明进行操作。
cd 转到 grails-guides/grails-controller-testing/complete ,即可直接转到已完成的示例 |
3 编写应用程序
我们将编写一个包含一个域类 Student
的简单 CRUD 应用程序。
我们将有一个控制器和一个服务类。我们将在这种情况下探索控制器测试方法。
3.1 域类和脚手架
我们将创建一个域类 Student
。
域类满足模型视图控制器 (MVC) 模式中的 M,并表示映射到基础数据库表的持久实体。在 Grails 中,域是一个位于 grails-app/domain 目录中的类。
> grails create-domain-class Student
将域属性 (name
和 grade
) 添加到已创建的域类中
package demo
import groovy.transform.CompileStatic
@CompileStatic
class Student {
String name
BigDecimal grade
String toString() {
name
}
}
如果使用 create-domain-class
命令生成了域类,则会为域类创建 Spock 规范。
删除此文件
$ src/test/groovy/demo/StudentSpec.groovy
在如何测试域类约束?指南中了解有关测试域类的更多信息。
3.2 静态脚手架
借助 Grails 静态脚手架功能,为这个域类生成控制器和视图。
在Grails 文档中了解有关脚手架的更多信息。 |
> grails generate-all demo.Student
前面的命令会生成示例控制器和 GSP 视图。它为生成的控制器生成 Spock 规范。
.grails-app/controllers/demo/StudentController.groovy
.grails-app/views/student/create.gsp
.grails-app/views/student/edit.gsp
.grails-app/views/student/index.gsp
.grails-app/views/student/show.gsp
.src/test/groovy/demo/StudentControllerSpec.groovy
这些构件为 Student
域类提供 CRUD 功能。
我们已经编辑生成的控制器,并将处理事务行为的代码移到了一个服务中。可以在 complete
文件夹中查看完整的解决方案。
Grails 团队不鼓励将核心应用程序逻辑嵌入控制器中,因为它不利于重用和清晰地分离关注点。控制器的职责应该是处理请求以及创建或准备响应。控制器可以直接生成响应或委托给视图。
3.3 单元测试 - Index 方法
index()
动作方法将 studentList
和 studentCount
作为模型返回到视图。
下一段代码示例显示了 StudentController 的index 方法。
package demo
import static org.springframework.http.HttpStatus.CREATED
import static org.springframework.http.HttpStatus.OK
import static org.springframework.http.HttpStatus.NOT_FOUND
import static org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.context.MessageSource
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
class StudentController {
static allowedMethods = [save: 'POST',
update: 'PUT',
delete: 'DELETE',]
StudentService studentService
MessageSource messageSource
def index(Integer max) {
params.max = Math.min(max ?: 10, 100)
List<Student> studentList = studentService.list(params)
respond studentList, model: [studentCount: studentService.count()]
}
}
前面的动作使用服务(合作者)获取一些对象。然后,它将那些对象作为模型返回。我们将验证服务返回的对象的确用作模型。为此,我们将使用一个 Stub 来帮助我们。
我何时应该使用 Mock,何时又应该使用 Stub?
来自书籍 Spock Up & Running
如果测试关注于证明测试主题与协作者以特定方式交互,请使用 mock。如果协作者以某种方式执行的操作公开了测试主题中的特定行为,则您要测试的是该行为的结果,请使用 stub
package demo
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {
def 'Test the index action returns the correct model'() {
given:
List<Student> sampleStudents = [new Student(name: 'Nirav', grade: 100),
new Student(name: 'Jeff', grade: 95),
new Student(name: 'Sergio', grade: 90),]
controller.studentService = Stub(StudentService) {
list(_) >> sampleStudents
count() >> sampleStudents.size()
}
when: 'The index action is executed'
controller.index()
then: 'The model is correct'
model.studentList (1)
model.studentList.size() == 3
model.studentList.find { it.name == 'Nirav' && it.grade == 100 }
model.studentList.find { it.name == 'Jeff' && it.grade == 95 }
model.studentList.find { it.name == 'Sergio' && it.grade == 90 }
model.studentCount == 3
}
}
1 | 我们可以验证该模型包含我们期望的信息 |
3.4 单元测试 - 保存方法
save()
动作方法使用命令对象作为参数。
查看指南 使用命令对象处理表单数据 以了解有关命令对象的更多信息。
package demo
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
@GrailsCompileStatic
class StudentSaveCommand implements Validateable {
String name
BigDecimal grade
static constraints = {
name nullable: false
grade nullable: false, min: 0.0
}
}
package demo
import static org.springframework.http.HttpStatus.CREATED
import static org.springframework.http.HttpStatus.OK
import static org.springframework.http.HttpStatus.NOT_FOUND
import static org.springframework.http.HttpStatus.NO_CONTENT
import org.springframework.context.MessageSource
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
class StudentController {
static allowedMethods = [save: 'POST',
update: 'PUT',
delete: 'DELETE',]
StudentService studentService
MessageSource messageSource
@CompileDynamic
def save(StudentSaveCommand cmd) {
if (cmd.hasErrors()) { (1)
respond cmd.errors, [model: [student: cmd], view: 'create']
return
}
Student student = studentService.save(cmd, true) (2)
if (student.hasErrors()) { (3)
respond student.errors, view:'create'
return
}
request.withFormat { (4)
form multipartForm { (5)
String msg = messageSource.getMessage('student.label', [] as Object[], 'Student', request.locale)
flash.message = messageSource.getMessage('default.created.message', [msg, student.id] as Object[], 'Student created', request.locale)
redirect(action: 'show', id: student.id)
}
'*' { respond student, [status: CREATED] }
}
}
}
1 | Grails 在命令对象中绑定参数,并在控制器保存动作开始前调用 validate()。 |
2 | 如果命令对象有效,则它会尝试在服务(协作者)的帮助下保存新学生。 |
3 | 检查协作者响应。动作结果取决于协作者响应。 |
4 | 如果一切顺利,它将根据内容类型返回不同的答案。 |
5 | 例如,对于“application/x-www-form-urlencoded 内容类型,它重定向到 展示动作以显示学生。 |
我们可以通过以下功能方法对其进行单元测试
package demo
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {
def 'If you save without supplying name and grade(both required) you remain in the create form'() {
when:
request.contentType = FORM_CONTENT_TYPE (1)
request.method = 'POST' (2)
controller.save()
then:
model.student
view == 'create' (3)
}
}
1 | 多亏了 @TestFor 注释,我们改变了请求内容类型。根据 ContentType,正在测试的动作输出了不同的状态代码,请参阅 request.withFormat 闭包。 |
2 | 多亏了 @TestFor 注释,我们改变了请求方法 |
3 | 我们可以验证所使用的视图。 |
package demo
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {
def 'if the users supplies both name and grade, save is successful '() {
given:
String name = 'Nirav'
BigDecimal grade = 100
Long id = 1L
controller.studentService = Stub(StudentService) {
save(_, _) >> new Student(name: name, grade: grade, id: id)
read(_) >> new Student(name: name, grade: grade, id: id)
}
when:
request.method = 'POST'
request.contentType = FORM_CONTENT_TYPE
params['name'] = name (1)
params['grade'] = grade
controller.save() (2)
then: 'a message indicating that the user has been saved is placed'
flash.message (3)
and: 'the user is redirected to show the student'
response.redirectedUrl.startsWith('/student/show') (4)
and: 'a found response code is used'
response.status == 302 (5)
}
}
1 | 按常规方式提供参数。 |
2 | 不要向动作方法提供参数。无需参数即可调用动作方法。Grails 会进行绑定。它更全面地模拟了实际代码中的情况。 |
3 | 您可以检查是否已填充闪存消息 |
4 | 验证重定向的 URL 是否符合我们的预期。 |
5 | 您可以验证预期的状态代码是否存在。 |
package demo
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {
void 'JSON payload is bound to the command object. If the student is saved, a 201 is returned'() {
given:
String name = 'Nirav'
BigDecimal grade = 100
Long id = 1L
controller.studentService = Stub(StudentService) {
save(_, _) >> new Student(name: name, grade: grade, id: id)
}
when: 'json request is sent with domain conversion'
request.method = 'POST'
request.json = '{"name":"' + name + '","grade":' + grade + '}' (1)
controller.save()
then: 'CREATED status code is set'
response.status == 201 (2)
}
}
1 | Grails 支持对命令对象进行 JSON 请求的数据绑定。 |
2 | 您可以验证预期的状态代码是否存在。 |
3.5 测试允许的方法
我们已限制保存仅使用属性 allowedMethods
进行 POST 请求的操作。
allowedMethods:基于 HTTP 请求方法限制对控制器操作的访问,在使用不正确的 HTTP 方法时发送 405(方法不允许)错误代码。
static allowedMethods = [save: 'POST',
update: 'PUT',
delete: 'DELETE',]
我们可以通过单元测试对其进行验证
package demo
import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED
import static javax.servlet.http.HttpServletResponse.SC_OK
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
import spock.lang.Unroll
class StudentControllerAllowedMethodsSpec extends Specification implements ControllerUnitTest<StudentController> {
@Unroll
def "StudentController.save does not accept #method requests"(String method) {
when:
request.method = method
controller.save()
then:
response.status == SC_METHOD_NOT_ALLOWED
where:
method << ['PATCH', 'DELETE', 'GET', 'PUT']
}
def "StudentController.save accepts POST requests"() {
when:
request.method = 'POST'
controller.save()
then:
response.status == SC_OK
}
}
3.6 功能测试
我们将使用功能测试来验证索引方法返回包含学生列表的 JSON 有效负载。
我们可以使用 Rest Client Builder Grails 插件中的 RestBuilder
。将依赖关系导入到 build.gradle
中。
dependencies {
testCompile "org.grails:grails-datastore-rest-client"
}
我们可以在 RestBuilder
API 中使用适当的 URL 来调用控制器操作。我们将得到一个 RestResponse
对象,利用该对象,我们可以验证控制器的返回项。
下一个功能方法调用 index()
操作,并返回学生 JSON 列表。
package demo
import grails.plugins.rest.client.RestBuilder
import grails.plugins.rest.client.RestResponse
import grails.testing.mixin.integration.Integration
import spock.lang.Shared
import spock.lang.Specification
import grails.transaction.Rollback
@Integration
@Rollback
class StudentControllerIntSpec extends Specification {
@Shared RestBuilder rest = new RestBuilder()
def setup() {
Student.saveAll(
new Student(name: 'Nirav', grade: 100),
new Student(name: 'Jeff', grade: 95),
new Student(name: 'Sergio', grade: 90),
)
}
def 'test json in URI to return students'() {
when:
RestResponse resp = rest.get("http://localhost:${serverPort}/student.json") (1) (2)
then:
resp.status == 200 (3)
resp.json.size() == 3
resp.json.find { it.grade == 100 && it.name == 'Nirav' }
resp.json.find { it.grade == 95 && it.name == 'Jeff' }
resp.json.find { it.grade == 90 && it.name == 'Sergio' }
}
}
1 | .json 可以附加到 URL 以声明我们需要一个 JSON 响应。 |
2 | serverPort 会自动注入。 |
3 | 使用 RestResponse 对象来验证状态代码、JSON 负载等。 |
serverPort 属性会自动注入。它包含功能测试期间 Grails 应用程序运行的随机端口。 |
4 小结
概括本指南,我们学习了如何...
-
使用控制器单元测试属性来验证
model, views, status and redirect
。 -
在控制器操作方法中验证与命令对象的数据绑定
-
使用集成测试调用控制器和从头到尾进行测试
5 运行应用程序
运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew test