Groovy And Grails Testing Techniques

About Me

Object Computing Inc.

  • St. Louis, MO
  • ~140 Strong
  • In Business 24+ Years
  • Open Source All Along
  • Home Of Grails!!

The Grails Team At OCI

Grails At OCI

  • 30+ Releases Per Year

    • 2.5.x, 3.0.x, 3.1.x, 3.2.x, 3.3.x
    • Shorter Release Cycles

      • Easier To Upgrade
      • Less Time To Wait For Enhancements

Agenda

  • The Basics
  • Grails Testing

    • Domain Classes
    • Command Objects
    • Tag Libraries
    • Grails 3.3 Trait Based Testing Library
    • GEB
    • REST APIs

The Basics

Automated Testing

  • Unit Tests
  • Integration Tests
  • CI Server

    • Broken Windows

Are Automated Tests Important?

  • Of Course!

Testing And Groovy

  • Dynamic Typing vs. Static Typing
  • Dynamic Dispatch
  • Whole Classes Of Errors Now Have To Be Dealt With
  • What Kind Of Test Is A Compiler?

JUnit, Spock, Etc…​

  • JUnit

    • Long Time Player
    • Well Respected In The JVM Community
    • Works Great With Groovy
    • GroovyTestCase extends TestCase
    • Just Works™

  • Spock

    • Many Great Features
    • Expressive Test Structure
    • De-Facto Standard In Groovy Community

Running Tests

Running Unit Tests

grails test-app unit:

Or…​

./gradlew test

Running Integration Tests

./gradlew integrationTest

Or…​

./gradlew iT

Running All Tests

./gradlew check

Grails Testing

Testing Domain Classes

Domain Class

package demo

class Person {
    String firstName
    String lastName
    Integer age

    static constraints = {
        age range: 0..120
    }
}

Domain Class Validation Unit Test

@TestFor(Person)
class PersonSpec extends Specification {

    @Unroll('validate on a person with age #personAge should have returned #shouldBeValid')
    void "test age validation"() {
        expect:
        new Person(age: personAge).validate(['age']) == shouldBeValid

        where:
        personAge | shouldBeValid
        -2        | false
        -1        | false
        0         | true
        1         | true
        119       | true
        120       | true
        121       | false
        122       | false
    }
}

Testing Controllers

Controller

package demo

class DemoController {

    def index() { }

    def createWidget(Widget w) {
        [widget: w]
    }
}

class Widget {
    int width
    int height
    String name

    static constraints = {
        width min: 1
        height min: 1
    }
}

Command Object Unit Test

@TestFor(DemoController)
class DemoControllerSpec extends Specification {
    void "test command object population"() {
        when:
        params.width = 4
        params.height = 7
        params.name = 'fidget'
        def model = controller.createWidget()
        def widget = model.widget

        then:
        widget.width == 4
        widget.height == 7
        widget.name == 'fidget'
    }
}

String Conversion Unit Test

grails:
    databinding:
        convertEmptyStringsToNull: false
@TestFor(DemoController)
class DemoControllerSpec extends Specification {
    void "test that empty strings are not converted to null"() {
        when: 'conversion of empty strings to null is disabled in application.yml'
        params.width = 4
        params.height = 7
        params.name = ''
        def model = controller.createWidget()
        def widget = model?.widget

        then: 'unit tests respect that config value'
        !widget.hasErrors()
        widget.width == 4
        widget.height == 7
        widget.name == ''
    }
}

Testing Services

Services

package demo

import grails.transaction.Transactional

@Transactional
class MoneyService {

    def bankService

    List<String> getBankNames() {
        bankService.bankNames*.toUpperCase()
    }
}
package demo

class BankService {

    List<String> getBankNames() {
        ['Commerce', 'Fidelity', 'Mark Twain']
    }
}

Configuring Beans In Test

package demo

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

@TestFor(MoneyService)
class MoneyServiceSpec extends Specification {
    static doWithSpring = {
        bankService(DummyBankSvc)
    }

    void "test something"() {
        expect:
        service.bankNames == ['BANK ONE', 'BANK TWO', 'BANK THREE']
    }
}

class DummyBankSvc {
    List<String> getBankNames() {
        ['Bank One', 'Bank Two', 'Bank Three']
    }
}

Testing Tag Libraries

Tag Library

package demo

class HelperTagLib {
    static defaultEncodeAs = [taglib:'html']

    static namespace = 'demo'

    def sayHello = { attrs ->
        out << "Hi there ${attrs.name}"
    }

    def repeat = { attrs, body ->
        int count = attrs.int('count', 3)
        count.times {
            out << body()
        }
    }
}

Testing Simple Tag

@TestFor(HelperTagLib)
class HelperTagLibSpec extends Specification {

    void "test sayHello"() {
        expect:
        applyTemplate('<demo:sayHello name="Jeff"/>') == 'Hi there Jeff'
    }
}

Testing Tag With Body

@TestFor(HelperTagLib)
class HelperTagLibSpec extends Specification {

    void "test repeat"() {
        expect:
        applyTemplate('<demo:repeat count="4">the body </demo:repeat>') == 'the body the body the body the body '
    }
}

Testing Tag With Body

@TestFor(HelperTagLib)
class HelperTagLibSpec extends Specification {

    void "test repeat with no count"() {
        expect:
        applyTemplate('<demo:repeat>the body </demo:repeat>') == 'the body the body the body '
    }
}

Testing Tag Method Call

@TestFor(HelperTagLib)
class HelperTagLibSpec extends Specification {

    void "test tag method calls"() {
        expect:
        tagLib.sayHello(name: 'Jeffrey') == 'Hi there Jeffrey'
        tagLib.repeat(count: 4) { 'yes' } == 'yesyesyesyes'
        tagLib.repeat() { 'yes' } == 'yesyesyes'
    }
}

Testing Domain Classes With Traits

Domain Class

package demo

class Person {
    String firstName
    String lastName
    Integer age

    static constraints = {
        age range: 0..120
    }
}

Domain Class Validation Unit Test

class PersonSpec extends Specification implements DomainUnitTest<Person> {

    @Unroll('validate on a person with age #personAge should have returned #shouldBeValid')
    void "test age validation"() {
        expect:
        new Person(age: personAge).validate(['age']) == shouldBeValid

        where:
        personAge | shouldBeValid
        -2        | false
        -1        | false
        0         | true
        1         | true
        119       | true
        120       | true
        121       | false
        122       | false
    }
}

Testing Controllers With Traits

Controller

package demo

class DemoController {

    def index() { }

    def createWidget(Widget w) {
        [widget: w]
    }
}

class Widget {
    int width
    int height
    String name

    static constraints = {
        width min: 1
        height min: 1
    }
}

Command Object Unit Test

class DemoControllerSpec extends Specification implements ControllerUnitTest<DemoController> {
    void "test command object population"() {
        when:
        params.width = 4
        params.height = 7
        params.name = 'fidget'
        def model = controller.createWidget()
        def widget = model.widget

        then:
        widget.width == 4
        widget.height == 7
        widget.name == 'fidget'
    }
}

String Conversion Unit Test

grails:
    databinding:
        convertEmptyStringsToNull: false
class DemoControllerSpec extends Specification implements ControllerUnitTest<DemoController> {
    void "test that empty strings are not converted to null"() {
        when: 'conversion of empty strings to null is disabled in application.yml'
        params.width = 4
        params.height = 7
        params.name = ''
        def model = controller.createWidget()
        def widget = model?.widget

        then: 'unit tests respect that config value'
        !widget.hasErrors()
        widget.width == 4
        widget.height == 7
        widget.name == ''
    }
}

Testing Services With Traits

Services

package demo

class MoneyService {

    def bankService

    List<String> getBankNames() {
        bankService.bankNames*.toUpperCase()
    }
}
package demo

class BankService {

    List<String> getBankNames() {
        ['Commerce', 'Fidelity', 'Mark Twain']
    }
}

Configuring Beans In Test

package demo

import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

class MoneyServiceSpec extends Specification implements ServiceUnitTest<MoneyService> {

    Closure doWithSpring() {{->
        bankService(DummyBankSvc)
    }}

    void "test something"() {
        expect:
        service.bankNames == ['BANK ONE', 'BANK TWO', 'BANK THREE']
    }
}

class DummyBankSvc {
    List<String> getBankNames() {
        ['Bank One', 'Bank Two', 'Bank Three']
    }
}

Testing Tag Libraries With Traits

Tag Library

package demo

class HelperTagLib {
    static defaultEncodeAs = [taglib:'html']

    static namespace = 'demo'

    def sayHello = { attrs ->
        out << "Hi there ${attrs.name}"
    }

    def repeat = { attrs, body ->
        int count = attrs.int('count', 3)
        count.times {
            out << body()
        }
    }
}

Testing Simple Tag

class HelperTagLibSpec extends Specification implements TagLibUnitTest<HelperTagLib> {

    void "test sayHello"() {
        expect:
        applyTemplate('<demo:sayHello name="Jeff"/>') == 'Hi there Jeff'
    }
}

Testing Tag With Body

class HelperTagLibSpec extends Specification implements TagLibUnitTest<HelperTagLib> {

    void "test repeat"() {
        expect:
        applyTemplate('<demo:repeat count="4">the body </demo:repeat>') == 'the body the body the body the body '
    }
}

Testing Tag With Body

class HelperTagLibSpec extends Specification implements TagLibUnitTest<HelperTagLib> {

    void "test repeat with no count"() {
        expect:
        applyTemplate('<demo:repeat>the body </demo:repeat>') == 'the body the body the body '
    }
}

Testing Tag Method Call

class HelperTagLibSpec extends Specification implements TagLibUnitTest<HelperTagLib> {

    void "test tag method calls"() {
        expect:
        tagLib.sayHello(name: 'Jeffrey') == 'Hi there Jeffrey'
        tagLib.repeat(count: 4) { 'yes' } == 'yesyesyesyes'
        tagLib.repeat() { 'yes' } == 'yesyesyes'
    }
}

Testing With Geb

GebSpec

import geb.spock.GebSpec
import grails.test.mixin.integration.Integration
import spock.lang.Stepwise

@Integration
@Stepwise
class PersonCrudFunctionalSpec extends GebSpec {

}

Filling Out A Form

void "test creating people"() {
        when:
        to CreatePersonPage

        then:
        at CreatePersonPage

        when:
        populateCreatePersonForm 'Geddy', 'Lee', '63'

        then:
        at ShowPersonPage

        when:
        to CreatePersonPage

        then:
        at CreatePersonPage

        when:
        populateCreatePersonForm 'Alex', 'Lifeson', '63'

        then:
        at ShowPersonPage
    }

The CreatePersonPage

package demo.pages

import geb.Page

class CreatePersonPage extends Page {

    static url = '/person/create'

    static at = {
        title == 'Create Person'
    }

    static content = {
        submitButton { $('#create', 0) }
        firstNameInputField { $('#firstName', 0) }
        lastNameInputField { $('#lastName', 0) }
        ageInputField { $('#age', 0) }
    }

    void populateCreatePersonForm(String firstName, String lastName, String age) {
        firstNameInputField << firstName
        lastNameInputField << lastName
        ageInputField << age
        submitButton.click()
    }
}

Inspecting A Page

void "test retrieving people"() {
        when:
        to PersonListPage

        then:
        numberOfPersonRows == 2
    }

The PersonListPage

package demo.pages

import geb.Page

class PersonListPage extends Page {

    static url = '/person'

    static at = {
        title == 'Person List'
    }

    static content = {
        personRows { $('table tbody tr') }
        numberOfPersonRows { personRows.size() }
    }
}

Testing REST APIs

Domain Class

package demo

class Album {
    String artistName
    String title
    String genre
}

Rest Controller

class MusicController extends RestfulController<Album> {

    static responseFormats = ['json', 'xml']

    public MusicController() {
        super(Album)
    }
}

URL Mapping

package testingdemo

class UrlMappings {

    static mappings = {
        "/$controller/$action?/$id?(.$format)?"{
            constraints {
                // apply constraints here
            }
        }

        "/albums"(resources: 'music')
        "/genre/$genre/albums"(controller: 'music')

        "/"(view:"/index")
        "500"(view:'/error')
        "404"(view:'/notFound')
    }
}

RestBuilder From Datastore Rest Client

dependencies {
    // ...

    testCompile "org.grails:grails-datastore-rest-client"
}

Create Integration Test

grails create-integration-test demo.MusicFunctional

    BUILD SUCCESSFUL

    | Created src/integration-test/groovy/demo/MusicFunctionalSpec.groovy

Integration Test

@Integration
@Stepwise
class MusicFunctionalSpec extends Specification {

    @Shared
    def rest = new RestBuilder()

}

Test No Data

@Integration
@Stepwise
class MusicFunctionalSpec extends Specification {

    @Shared
    def rest = new RestBuilder()

    void "test that no albums exist"() {
        when:
        def resp = rest.get("http://localhost:${serverPort}/albums")
        def contentType = resp.headers.getContentType()

        then:
        resp.status == OK.value()
        contentType.subtype == 'json'
        contentType.type == 'application'
        resp.json.size() == 0
    }
}

Test Create Albums

@Integration
@Stepwise
class MusicFunctionalSpec extends Specification {

    @Shared
    def rest = new RestBuilder()

    void "test creating albums"() {
        when:
        def resp = rest.post("http://localhost:${serverPort}/albums") {
            json {
                artistName = 'King Crimson'
                title = 'Red'
                genre = 'PROGRESSIVE_ROCK'
            }
        }
        def contentType = resp.headers.getContentType()

        then:
        resp.status == CREATED.value()
        contentType.subtype == 'json'
        contentType.type == 'application'

        and:
        resp.json.artistName == 'King Crimson'
        resp.json.title == 'Red'
        resp.json.genre == 'PROGRESSIVE_ROCK'

        // ...
    }
}

Test Retrieve Albums

@Integration
@Stepwise
class MusicFunctionalSpec extends Specification {

    @Shared
    def rest = new RestBuilder()

    void 'test retrieving list of albums defaults to JSON'() {
        when:
        def resp = rest.get("http://localhost:${serverPort}/albums")
        def contentType = resp.headers.getContentType()

        then:
        resp.status == OK.value()
        contentType.subtype == 'json'
        contentType.type == 'application'
        resp.json.size() == 4

        and:
        resp.json[0].artistName == 'King Crimson'
        resp.json[0].title == 'Red'
        resp.json[0].genre == 'PROGRESSIVE_ROCK'

        and:
        resp.json[1].artistName == 'Riverside'
        resp.json[1].title == 'Love, Fear and the Time Machine'
        resp.json[1].genre == 'PROGRESSIVE_ROCK'
        // ...
    }
}

Query Filter

class MusicController extends RestfulController<Album> {

    static responseFormats = ['json', 'xml']

    public MusicController() {
        super(Album)
    }
    protected List<Album> listAllResources(Map m) {
        Album.where {
            if(m.genre) {
                genre == m.genre
            }
        }.list()
    }
}

Test Query Filter

@Integration
@Stepwise
class MusicFunctionalSpec extends Specification {

    @Shared
    def rest = new RestBuilder()

    void "test retrieving albums by genre"() {
        when:
        def resp = rest.get("http://localhost:${serverPort}/genre/PROGRESSIVE_ROCK/albums")
        def contentType = resp.headers.getContentType()

        then:
        resp.status == OK.value()
        contentType.subtype == 'json'
        contentType.type == 'application'
        resp.json.size() == 2

        when:
        resp = rest.get("http://localhost:${serverPort}/genre/HEAVY_METAL/albums")
        contentType = resp.headers.getContentType()

        then:
        resp.status == OK.value()
        contentType.subtype == 'json'
        contentType.type == 'application'
        resp.json.size() == 1
        // ...
    }
}

Thank you!

/