显示导航

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

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

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

向创建的域类中添加域属性 (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 静态脚手架能力,为该域类生成一个 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() 动作方法返回一个 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 一书

如果测试关注于证明测试主题以特定方式与协作者进行交互,请使用模拟。如果协作者以某种方式表现会让测试主题暴露出特定行为,该行为的结果就是你要测试的内容,请使用桩

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() == 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() 操作方法使用命令对象作为参数。

查看指南 使用命令对象处理表单数据 以详细了解命令对象。

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) (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 操作以显示学生。

我们可以用以下特征方法进行单元测试

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 属性,我们已经将 save 操作限制为仅 POST 请求。

allowedMethods:根据 HTTP 请求方法限制对控制器操作的访问,在使用不正确的 HTTP 方法时发送 405 (Method Not Allowed) 错误代码。

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

@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

build.gradle
dependencies {
...
    testImplementation "io.micronaut:micronaut-http-client"
}

我们可以使用 HttpClient API 中的相应 URL 来调用控制器操作。我们将获得一个 HttpResponse 对象,借助该对象,我们可以验证控制器的返回值。

下一个特性方法调用 index() 操作并返回学生的 JSON 列表。

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

6 Grails 帮助

Object Computing, Inc. (OCI) 赞助本指南的撰写。OCI 提供多种咨询和支持服务。

OCI 是 Grails 的家园

认识团队