Grails 的 Controller 单元测试都有什么作用?如何利用好它?

Grails 的 Controller 单元测可以用来检查 controller 各个方面是否满足设计要求,包括

  • 验证 controller 允许的 methods,如 POST、GET、DELETE 等
  • 验证 controller 返回的 model,views、status 以及 redirect 是否正确
  • 验证 HTTP parameters 正确绑定到了一个 command object
  • 验证 JSON 请求串正确绑定到了一个 command object

注意,就是验证 controller 的逻辑,不涉及 service、domain 的验证。
所谓 controller 的逻辑就是什么情况下生成什么样的 model,使用什么 view 来渲染。不要把 controller 的工作职责赋予太多,这样不利于开发、测试和维护。

所谓单元测试,就是认为其他环节都是OK的,例如数据库读写都是正确的、service 执行都是正确的,就看你 controller 的逻辑是否正确。
也仅仅检查 controller 的逻辑,这样开发起来也不累。

用到的技术有

  • 使用 Stub 替代 service

其中用 Spock 的 Stub 技术替代 service 是一个必备技能。

简单说一下 Spock 的 Stub 技术和 Mock 技术使用的场景。Stub 是用来验证结果的,Mock 是用来验证行为的。Stub 用来验证某个动作产生的结果是否正确,例如 Controller 方法执行后,送给 view 的 model 对象是否正确,就属于使用 Stub 技术的范畴。
而如果需要验证 service 的某个方法被调用了多少次,就需要用 Mock 技术了。

测试 controller 的 action 正确生成了 model

来看下被测试的 controller 代码:

@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()]
    }

}

测试 controller 是否正确返回 model 的单元测试代码:

class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {
    def 'Test the index action returns the correct model'() {
        given:
        // 先准备一个service应该提供的模型数据
        List<Student> sampleStudents = [new Student(name: 'Nirav', grade: 100),
                                        new Student(name: 'Jeff', grade: 95),
                                        new Student(name: 'Sergio', grade: 90),]
        // 使用 Stub 技术,让 list()、count() 方法返回指定的 model 数据
        controller.studentService = Stub(StudentService) {
            list(_) >> sampleStudents
            count() >> sampleStudents.size()
        }

        when: 'The index action is executed'
        controller.index()

        then: 'The model is correct'
        model.studentList 
        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()
    }
}

测试 command 对象被正确绑定了

首先看看 command 对象代码

@GrailsCompileStatic
class StudentSaveCommand implements Validateable {
    String name
    BigDecimal grade

    static constraints = {
        name nullable: false
        grade nullable: false, min: 0.0
    }
}

然后看看controller代码

@CompileStatic
class StudentController {

    static allowedMethods = [save: 'POST',
                             update: 'PUT',
                             delete: 'DELETE',]

    StudentService studentService

    MessageSource messageSource

    @CompileDynamic
    def save(StudentSaveCommand cmd) {
    	// command object 被绑定时会执行validate,有错的话,这里就能知道了
        if (cmd.hasErrors()) { 
            respond cmd.errors, [model: [student: cmd], view: 'create']
            return
        }
		
        Student student = studentService.save(cmd)
        // 保存实体对象,如果失败,这里就能知道了
        if (student.hasErrors()) { 
            respond student.errors, view:'create'
            return
        }
		// 下面是根据不同的 content-type 执行不同的响应逻辑,表单类型则重定向,其他类型则用本action的视图渲染结果。
        request.withFormat {  
            form multipartForm {  
                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] }
        }
    }

}

现在可以来看看 controller 的单元测试代码了,注意是单元测测试 unit test 而不是 integration test 或 functional test,因此是不会真的访问数据库的。

class StudentControllerSpec extends Specification implements ControllerUnitTest<StudentController> {

	// 验证 model 和 view 按照 controller 需求设计执行了
    def 'If you save without supplying name and grade(both required) you remain in the create form'() {

        when:
        // 可以修改 content-type 类型,让测试方便了很多,这里一定要指定content-type
        // 否则 content-type 默认是 TEXT_JSON 类型,导致解析json串,从而报错
        request.contentType = FORM_CONTENT_TYPE 
        // 还可以指定 http method 类型
        request.method = 'POST' 
        controller.save()

        then:
        model.student
        // 检查 controller 最终使用的 view
        view == 'create' 
    }

	// 验证不同的 content-type 有不同的响应内容
	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 
        params['grade'] = grade
        controller.save() 

        then: 'a message indicating that the user has been saved is placed'
        flash.message 
		// 用 and: 可以进行额外的检查
        and: 'the user is redirected to show the student'
        response.redirectedUrl.startsWith('/student/show') 
		// 检查 http status code 是不是 302 重定向
        and: 'a found response code is used'
        response.status == 302 
    }
    
    // 验证 json 串是否能绑定到 command object
    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'
        // 将请求设置为一个json串,如果更复杂的json串,可以用一个函数来提供,在函数中使用 JsonStreamingBuilder 来构建。
        request.json = '{"name":"' + name + '","grade":' + grade + '}' 
        controller.save()

        then: 'CREATED status code is set'
        // 为什么不是 200 呢?因为 201 更准确,它表示一个新的资源被创建了。前端工程师需要小心了,别只判断 200 啊。
        response.status == 201 
    }

	// 验证是否支持规定的 http methods
	// 技巧:这里测试方法可以加一个参数,其实呢也可以不加的,但是这样写更符合语法吧,因为可以先定义 method 变量。
    @Unroll
    def "StudentController.save does not accept #method requests"(String method) {
        when:
        request.method = method
        controller.save()

        then:
        // 可以再看看还有哪些 SC 常量,SC = status code
        response.status == SC_METHOD_NOT_ALLOWED

        where:
        // 这是 where data table 的一种简洁用法,另外一种是用 data table 的方式来定义测试数据
        method << ['PATCH', 'DELETE', 'GET', 'PUT']
    }
}

功能测试、验收测试

就是我们通常理解的集成测试,黑盒测试。是会真实发送 HTTP 请求、读写数据库的,和实际运行环境基本一致了。

对 REST 接口,可以使用 任何 HTTP 库,比如 Java 自带的 java.net.HttpConnection,或者 apache HttpClient 或者 Micronaut-http-client。

个人经验,还是使用 Java 自带的 HttpConnection 类比较简单,代码如下。
注意,如果 controller 返回非 200 的 status code,那么要用 errorStream 取响应文本,否则用 inputStream 取响应文本,这个设计有点多余。

    void "请求用户注册接口"() {
        when:
        HttpURLConnection post = new URL("${baseUrl}/api/user/register").openConnection() as HttpURLConnection
        def message = ''
        //def message = '{"message":"this is a message"}'
        post.setRequestMethod("POST")
        post.setDoOutput(true)
        post.setRequestProperty("Content-Type", "application/json")
        post.setRequestProperty("Accept", "application/json")
        post.getOutputStream().write(message.getBytes("UTF-8"))
        String responseText
        if (post.responseCode in [200, 299]) {
            responseText = post.inputStream.text
        } else {
            responseText = post.errorStream.text
        }
        println(responseText)
        then:
        post.responseCode == 200
    }

下面是使用 Micronaut 的 HttpClient 方法。

先导入依赖

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

利用 functional test 启动服务,并发送 REST 请求,最后验证结果。

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') 
        HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, Argument.of(List, Map)) 

        then:
        resp.status == HttpStatus.OK 
        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)
            }
        }
    }
}

想看具体的代码,在这里 github

参考资料

后记

问题一:Command Object 中的 service 如何替换为 Stub 对象?

因为 unit test 时,不会执行 spring 依赖注入,所以 command object 如果引用了 service ,那么需要手工替换成 spock 的 stub 对象,怎么替换?
目前没有找到好方法,只能将替换了 service 的command object 作为 action 方法参数进行调用了。
上面这种方法是错误的,会导致gson view文件不被使用,只是简单地将 respond 的参数序列化为 json 串而已。
因此要正确测试 controller action 必须调用无参数 action 方法。例如

controller.save() 

一个绕过方案是,在 action 方法中判断 service 是否为空,为空则用 controller 的服务。这样单元测试时就可以对服务进行替换了。
这样做后,gson view 能正确使用,但 model 还是为空的。

    def register(RegisterCommand registerCommand) {
        // 检查用户名是否已经存在
        if (userService.findBy(registerCommand.userName)) {
            respond(new ApiResponse(用户名已存在))
            return
        }
        if (!registerCommand.sequenceService){
            registerCommand.sequenceService = sequenceService
        }
        userService.saveUser(registerCommand.createUser())
        respond(new ApiResponse(成功))
    }

问题二:respond 非 domain object 时,model 对象没有值。

只能通过 response.json 来验证结果是否正确,无法知道送给 GSON Views 的 model 是否正确。
没有找到原因。

REST profile 的项目和 web profile 的项目 controller action 的行为不一样

grails v4.0.1 版本及以上,web profile 的项目,controller action 方法执行后,controller unit test 的 model 中是有 action respond 的数据的,但是 rest profile 却没有。可能是一个 bug。需要进一步和 grails 开发人员联系问问。

How to mock springSecurityService in an unit test ?

下面摘自我在 stackoverflow 上的回答

When you use controller unit test with spring security rest plugin in Grails v4/v3, if your controller method reference springSecurityService methods like ‘athenticatedUser’, there will be NullPointException, because springSecurityService is not autowired into the spring application context.

Add code like below, you can inject springSecurityService and mock it’s methods.

class GuessControllerSpec extends Specification implements ControllerUnitTest<GuessController> {

	@Override
	Closure doWithSpring() {
	    return {
	        // mock method
	        SpringSecurityService.metaClass.getCurrentUser = {return new User()}
	        // inject into spring context
	        springSecurityService(SpringSecurityService)
	    }
	}
	...
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值