Grails 控制器测试
在本指南中,我们探讨在 Grails 中测试控制器。
作者: Nirav Assar、Sergio del Amo
Grails 版本 5.0.1
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和交付!.
2 入门
在本指南中,您将探讨 Grails 中的控制器测试。我们将重点关注一些最常见的用例
单元测试
-
验证允许的方法。
-
验证模型、视图、状态和重定向
-
在与服务一起使用时使用存根
-
验证绑到命令对象的 HTTP 参数
-
验证绑到命令对象的 JSON 有效负载
功能测试
-
JSON 响应、状态代码
2.1 您需要什么
要完成本指南,您需要具备以下条件
-
一些时间
-
一个合适的文本编辑器或 IDE
-
安装了 JDK 1.8 或更高版本,并正确配置了
JAVA_HOME
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 编写应用程序
我们将编写一个简单的 CRUD 应用程序,涉及一个域类 Student
。
我们将有一个 Controller 类和一个 Service 类。我们将在此上下文中探索 Controller 测试方法。
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 静态脚手架能力,为该域类生成一个 Controller 和视图。
在Grails 文档中了解有关脚手架的更多信息。 |
> grails generate-all demo.Student
上一条命令生成了一个示例 controller 和 GSP 视图。它为生成的 controller 生成了一个 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 功能。
我们编辑了生成的 controller,并将处理事务行为的代码移到了服务中。您可以在 complete
文件夹中检查完整的解决方案。
Grails 团队不建议在 controller 中嵌入核心应用程序逻辑,因为它不会促进重用和清晰地分离问题。Controller 的职责应该是处理请求并创建或准备响应。Controller 可以直接生成响应或委托给视图。
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 一书
如果测试关注于证明测试主题以特定方式与协作者进行交互,请使用模拟。如果协作者以某种方式表现会让测试主题暴露出特定行为,该行为的结果就是你要测试的内容,请使用桩
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() == sampleStudents.size()
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 == sampleStudents.size()
}
}
1 | 我们可以验证该模型包含我们预期的信息 |
3.4 单元测试 - Save 方法
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) (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 从命令对象绑定参数,并在开始控制器 save 操作之前调用 validate()。 |
2 | 如果该命令对象有效,它将尝试借助服务(协作者)保存该新学生。 |
3 | 检查协作者反应。操作结果取决于协作者的反应。 |
4 | 如果一切顺利,它将根据 Content Type 返回不同的答案。 |
5 | 例如,对于 “application/x-www-form-urlencoded Content Type,它重定向到 show 操作以显示学生。 |
我们可以用以下特征方法进行单元测试
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
属性,我们已经将 save 操作限制为仅 POST 请求。
allowedMethods:根据 HTTP 请求方法限制对控制器操作的访问,在使用不正确的 HTTP 方法时发送 405 (Method Not Allowed) 错误代码。
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
@SuppressWarnings(['JUnitPublicNonTestMethod', 'JUnitPublicProperty'])
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 功能测试
我们将使用功能测试来验证 index 方法使用带有学生列表的 JSON 有效负载返回。
我们可以从 Micronaut HTTP 库使用 HttpClient
。将依赖项导入 build.gradle
。
dependencies {
...
testImplementation "io.micronaut:micronaut-http-client"
}
我们可以使用 HttpClient
API 中的相应 URL 来调用控制器操作。我们将获得一个 HttpResponse
对象,借助该对象,我们可以验证控制器的返回值。
下一个特性方法调用 index()
操作并返回学生的 JSON 列表。
package demo
import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.Shared
import spock.lang.Specification
@SuppressWarnings(['JUnitPublicNonTestMethod', 'JUnitPublicProperty'])
@Integration
class StudentControllerIntSpec extends Specification {
@Shared HttpClient client
StudentService studentService
@OnceBefore
void init() {
String baseUrl = "http://localhost:$serverPort"
this.client = HttpClient.create(baseUrl.toURL())
}
def 'test json in URI to return students'() {
given:
List<Serializable> ids = []
Student.withNewTransaction {
ids << studentService.save('Nirav', 100 as BigDecimal).id
ids << studentService.save('Jeff', 95 as BigDecimal).id
ids << studentService.save('Sergio', 90 as BigDecimal).id
}
expect:
studentService.count() == 3
when:
HttpRequest request = HttpRequest.GET('/student.json') (1)
HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, Argument.of(List, Map)) (2)
then:
resp.status == HttpStatus.OK (3)
resp.body()
resp.body().size() == 3
resp.body().find { it.grade == 100 && it.name == 'Nirav' }
resp.body().find { it.grade == 95 && it.name == 'Jeff' }
resp.body().find { it.grade == 90 && it.name == 'Sergio' }
cleanup:
Student.withNewTransaction {
ids.each { id ->
studentService.delete(id)
}
}
}
}
1 | .json 可以附于 URL 以声明我们要一个 JSON 响应。 |
2 | JSON 返回类型可以绑定到一个 Map 列表。 |
3 | 使用 HttpResponse 对象验证状态代码、JSON 负载等。 |
serverPort 属性是自动注入的。其中包含一个随机端口,Grails 应用程序在功能测试期间运行在该端口上。 |
4 总结
总结一下本指南的内容,我们了解了如何 . . .
-
使用控制器单元测试属性来验证
model、views、status 和 redirect
。 -
验证控制器操作方法中命令对象的数据绑定
-
使用集成测试调用控制器并对端到端进行测试
5 运行应用程序
运行单元测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew test
运行集成测试
./gradlew integrationTest