显示导航

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 如何完成指南

要开始进行,请执行以下操作

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

将域属性 (namegrade) 添加到已创建的域类中

grails-app/domain/demo/Student.groovy
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() 动作方法将 studentListstudentCount 作为模型返回到视图。

下一段代码示例显示了 StudentControllerindex 方法。

grails-app/controllers/demo/StudentController.groovy
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

src/test/groovy/demo/StudentControllerSpec.groovy
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() 动作方法使用命令对象作为参数。

查看指南 使用命令对象处理表单数据 以了解有关命令对象的更多信息。

grails-app/controllers/demo/StudentSaveCommand.groovy
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
    }
}
grails-app/controllers/demo/StudentController.groovy
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 内容类型,它重定向到 展示动作以显示学生。

我们可以通过以下功能方法对其进行单元测试

src/test/groovy/demo/StudentControllerSpec.groovy
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 我们可以验证所使用的视图。
src/test/groovy/demo/StudentControllerSpec.groovy
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 您可以验证预期的状态代码是否存在。
src/test/groovy/demo/StudentControllerSpec.groovy
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(方法不允许)错误代码。

grails-app/controllers/demo/StudentController.groovy
    static allowedMethods = [save: 'POST',
                             update: 'PUT',
                             delete: 'DELETE',]

我们可以通过单元测试对其进行验证

src/test/groovy/demo/StudentControllerAllowedMethodsSpec.groovy
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 中。

build.gradle
dependencies {
    testCompile "org.grails:grails-datastore-rest-client"
}

我们可以在 RestBuilder API 中使用适当的 URL 来调用控制器操作。我们将得到一个 RestResponse 对象,利用该对象,我们可以验证控制器的返回项。

下一个功能方法调用 index() 操作,并返回学生 JSON 列表。

src/integration-test/groovy/demo/StudentControllerIntSpec.groovy
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

6 Grails 帮助

Object Computing, Inc. (OCI) 赞助编写了此指南。提供各种咨询和支持服务。

OCI 是 Grails 的家

认识团队成员