(Quick Reference)

12.1.3 Unit Testing Domains - Reference Documentation

Authors: Graeme Rocher, Peter Ledbrook, Marc Palmer, Jeff Brown, Luke Daley, Burt Beckwith, Lari Hotari

Version: 2.3.8

12.1.3 Unit Testing Domains

Overview

The mocking support described here is best used when testing non-domain artifacts that use domain classes, to let you focus on testing the artifact without needing a database. But when testing persistence it's best to use integration tests which configure Hibernate and use a database.

Domain class interaction can be tested without involving a database connection using DomainClassUnitTestMixin. This implementation mimics the behavior of GORM against an in-memory ConcurrentHashMap implementation. Note that this has limitations compared to a real GORM implementation. The following features of GORM for Hibernate can only be tested within an integration test:

  • String-based HQL queries
  • composite identifiers
  • dirty checking methods
  • any direct interaction with Hibernate

However a large, commonly-used portion of the GORM API can be mocked using DomainClassUnitTestMixin including:

  • Simple persistence methods like save(), delete() etc.
  • Dynamic Finders
  • Named Queries
  • Query-by-example
  • GORM Events

If something isn't supported then GrailsUnitTestMixin's mockFor method can come in handy to mock the missing pieces. Alternatively you can write an integration test which bootstraps the complete Grails environment at a cost of test execution time.

The Basics

DomainClassUnitTestMixin is typically used in combination with testing either a controller, service or tag library where the domain is a mock collaborator defined by the Mock annotation:

import grails.test.mixin.TestFor
import spock.lang.Specification

@TestFor(BookController) @Mock(Book) class BookControllerSpec extends Specification { // … }

The example above tests the SimpleController class and mocks the behavior of the Simple domain class as well. For example consider a typical scaffolded save controller action:

class BookController {
    def save() {
        def book = new Book(params)
        if (book.save(flush: true)) {
            flash.message = message(
                    code: 'default.created.message',
                    args: [message(code: 'book.label', default: 'Book'), book.id])
            redirect(action: "show", id: book.id)
        }
        else {
            render(view: "create", model: [bookInstance: book])
        }
    }
}

Tests for this action can be written as follows:

import grails.test.mixin.TestFor
import spock.lang.Specification

@TestFor(BookController) @Mock(Book) class BookControllerSpec extends Specification { void "test saving an invalid book"() { when: controller.save()

then: model.bookInstance != null view == '/book/create' }

void "test saving a valid book"() { when: params.title = "The Stand" params.pages = "500"

controller.save()

then: response.redirectedUrl == '/book/show/1' flash.message != null Book.count() == 1 } }

Mock annotation also supports a list of mock collaborators if you have more than one domain to mock:

import grails.test.mixin.TestFor
import spock.lang.Specification

@TestFor(BookController) @Mock([Book, Author]) class BookControllerSpec extends Specification { // … }

Alternatively you can also use the DomainClassUnitTestMixin directly with the TestMixin annotation and then call the mockDomain method to mock domains during your test:

import grails.test.mixin.TestFor
import grails.test.mixin.TestMixin
import spock.lang.Specification
import grails.test.mixin.domain.DomainClassUnitTestMixin

@TestFor(BookController) @TestMixin(DomainClassUnitTestMixin) class BookControllerSpec extends Specification {

void setupSpec() { mockDomain(Book) }

void "test saving an invalid book"() { when: controller.save()

then: model.bookInstance != null view == '/book/create' }

void "test saving a valid book"() { when: params.title = "The Stand" params.pages = "500"

controller.save()

then: response.redirectedUrl == '/book/show/1' flash.message != null Book.count() == 1 } }

The mockDomain method also includes an additional parameter that lets you pass a Map of Maps to configure a domain, which is useful for fixture-like data:

mockDomain(Book, [
            [title: "The Stand", pages: 1000],
            [title: "The Shining", pages: 400],
            [title: "Along Came a Spider", pages: 300] ])

Testing Constraints

There are 4 types of validateable classes:

  1. Domain classes
  2. Classes marked with the Validateable annotation
  3. Command Objects which have been made validateable automatically
  4. Classes configured to be validateable via the grails.validateable.classes property in Config.groovy

The first 3 are easily testable in a unit test with no special configuration necessary as long as the test method is marked with TestFor or explicitly applies the GrailsUnitTestMixin using TestMixin. See the examples below.

// src/groovy/com/demo/MyValidateable.groovy
package com.demo

@grails.validation.Validateable class MyValidateable { String name Integer age

static constraints = { name matches: /[A-Z].*/ age range: 1..99 } }

// grails-app/domain/com/demo/Person.groovy
package com.demo

class Person { String name

static constraints = { name matches: /[A-Z].*/ } }

// grails-app/controllers/com/demo/DemoController.groovy
package com.demo

class DemoController {

def addItems(MyCommandObject co) { if(co.hasErrors()) { render 'something went wrong' } else { render 'items have been added' } } }

class MyCommandObject { Integer numberOfItems

static constraints = { numberOfItems range: 1..10 } }

// test/unit/com/demo/PersonSpec.groovy
package com.demo

import grails.test.mixin.TestFor import spock.lang.Specification

@TestFor(Person) class PersonSpec extends Specification {

void "Test that name must begin with an upper case letter"() { when: 'the name begins with a lower letter' def p = new Person(name: 'jeff')

then: 'validation should fail' !p.validate()

when: 'the name begins with an upper case letter' p = new Person(name: 'Jeff')

then: 'validation should pass' p.validate() } }

// test/unit/com/demo/DemoControllerSpec.groovy
package com.demo

import grails.test.mixin.TestFor import spock.lang.Specification

@TestFor(DemoController) class DemoControllerSpec extends Specification {

void 'Test an invalid number of items'() { when: params.numberOfItems = 42 controller.addItems()

then: response.text == 'something went wrong' }

void 'Test a valid number of items'() { when: params.numberOfItems = 8 controller.addItems()

then: response.text == 'items have been added' } }

// test/unit/com/demo/MyValidateableSpec.groovy
package com.demo

import grails.test.mixin.TestMixin import grails.test.mixin.support.GrailsUnitTestMixin import spock.lang.Specification

@TestMixin(GrailsUnitTestMixin) class MyValidateableSpec extends Specification {

void 'Test validate can be invoked in a unit test with no special configuration'() { when: 'an object is valid' def validateable = new MyValidateable(name: 'Kirk', age: 47)

then: 'validate() returns true and there are no errors' validateable.validate() !validateable.hasErrors() validateable.errors.errorCount == 0

when: 'an object is invalid' validateable.name = 'kirk'

then: 'validate() returns false and the appropriate error is created' !validateable.validate() validateable.hasErrors() validateable.errors.errorCount == 1 validateable.errors['name'].code == 'matches.invalid'

when: 'the clearErrors() is called' validateable.clearErrors()

then: 'the errors are gone' !validateable.hasErrors() validateable.errors.errorCount == 0

when: 'the object is put back in a valid state' validateable.name = 'Kirk'

then: 'validate() returns true and there are no errors' validateable.validate() !validateable.hasErrors() validateable.errors.errorCount == 0 } }

// test/unit/com/demo/MyCommandObjectSpec.groovy
package com.demo

import grails.test.mixin.TestMixin import grails.test.mixin.support.GrailsUnitTestMixin import spock.lang.Specification

@TestMixin(GrailsUnitTestMixin) class MyCommandObjectSpec extends Specification {

void 'Test that numberOfItems must be between 1 and 10'() { when: 'numberOfItems is less than 1' def co = new MyCommandObject() co.numberOfItems = 0

then: 'validation fails' !co.validate() co.hasErrors() co.errors['numberOfItems'].code == 'range.toosmall'

when: 'numberOfItems is greater than 10' co.numberOfItems = 11

then: 'validation fails' !co.validate() co.hasErrors() co.errors['numberOfItems'].code == 'range.toobig'

when: 'numberOfItems is greater than 1' co.numberOfItems = 1

then: 'validation succeeds' co.validate() !co.hasErrors()

when: 'numberOfItems is greater than 10' co.numberOfItems = 10

then: 'validation succeeds' co.validate() !co.hasErrors() } }

For validateable classes which are not one of the first 3 types listed above but are configured with the grails.validateable.classes property in Config.groovy, one additional step is required to test validation. GrailsUnitTestMixin provides a method named mockForConstraintsTests that will mock validation support for these classes. See the example below.

// src/groovy/com/demo/Book.groovy
package com.demo

class Book { String title String author

static constraints = { author minSize: 5 } }

// grails-app/conf/Config.groovy
grails.validateable.classes = [com.demo.Book]

// ...

// test/unit/com/demo/BookSpec.groovy
package com.demo

import grails.test.mixin.TestMixin import grails.test.mixin.support.GrailsUnitTestMixin import spock.lang.Specification

@TestMixin(GrailsUnitTestMixin) class BookSpec extends Specification {

void 'Test validation'() { given: mockForConstraintsTests Book

when: 'the author name has only 4 characters' def book = new Book() book.author = 'Jeff'

then: 'validation should fail' !book.validate() book.hasErrors() book.errors['author'] == 'minSize'

when: 'the author name has 5 characters' book.author = 'Jacob'

then: 'validation should pass' book.validate() !book.hasErrors() } }

Note that the mockForConstraintsTests method changes the behavior of the errors object such that something like book.errors'author' will evaluate to the name of the failed constraint, not a org.springframework.validation.FieldError object. This is convenient for unit tests. If your unit test really does want a reference to the org.springframework.validation.FieldError object use something like book.errors.getFieldError('author').

That's it for testing constraints. One final thing we would like to say is that testing the constraints in this way catches a common error: typos in the "constraints" property name which is a mistake that is easy to make and equally easy to overlook. A unit test for your constraints will highlight the problem straight away.