Testing Grails with Dru & Gru

Vladimír Oraný

Test Facilitator @ Agorapulse

@musketyr

https://agorapulse.github.io/dru/

https://agorapulse.github.io/gru/

 

Gru & Dru

Agenda

  • Dru
    • Example application
    • Preparing real-life test data
  • Gru
    • Testing HTTP endpoints in Grails
      • Using Grails testing framework
      • Using Gru

Dru

How to prepare data for you tests?

Example Application

❄ ❅ ❆ Gritter ❆ ❅ ❄

Example Application

Example Application

Example Application

Example Application

class Status {

    Date created = new Date()
    User user
    String text

    static hasMany = [engagements: Engagement]

}

class User {

    String username

}

class Engagement {

    User user
    Status status
    Date created = new Date()

}

Domain Classes

200.times {
    Status status = new Status(
            user: users[random.nextInt(users.size())],
            text: "${activities[random.nextInt(activities.size())]}" + 
                " ${directionsAndLocations[random.nextInt(directionsAndLocations.size())]}" + 
                " ${places[random.nextInt(places.size())]}",
            created: new Date(System.currentTimeMillis() - (10L * Math.abs(random.nextInt())))
    ).save(failOnError: true)
    
    random.nextInt(users.size()).times {
        User user = users[it]
        if (user != status.user && !status.engagements.any { it.user == user }) {
            status.addToEngagements(user: user, status: status).save(failOnError: true)
        }
    }
}

Sample Data (BootStrap)

@Transactional
class StatusService {

    List<Status> findTopStates(Date from, Date to, int max) {
        Status.findAllByCreatedBetween(from, to).sort(false) { a, b ->
            a.engagements?.size() <=> b.engagements?.size()
        }.reverse().take(max)
    }
}

Service under Test

Existing Data (View)

[
  {
    "id": 117,
    "created": "2018-02-28T07:44:35Z",
    "text": "Spreading some salt around Airdrie",
    "user": {
      "id": 1,
      "username": "John Sno"
    },
    "engagementsCount": 3,
    "engagements": [
      {
        "id": 616,
        "created": "2018-03-02T12:31:47Z",
        "user": {
          "id": 2,
          "username": "Ice Queen"
        }
      },
      {
        "id": 618,
        "created": "2018-03-02T12:31:47Z",
        "user": {
          "id": 4,
          "username": "Snow Destroyer"
        }
      },
      {
        "id": 617,
        "created": "2018-03-02T12:31:47Z",
        "user": {
          "id": 3,
          "username": "The Snow Buster"
        }
      }
    ]
  },
  {
    "id": 70,

Denormalized Data

    void 'load from JSON'() {
        when:
            List statuses = new JsonSlurper().parse(getStatusesSource())
        then:
            statuses
            statuses.size() == 25

        when:
            mockDomains User, Status, Engagement
            for (status in statuses) {
                new Status(status).save(failOnError: true)
            }
        then:
            noExceptionThrown()
            Status.list().size() == 25
            User.list().size() == 13
            Engagement.list().size() == 133
        when:
            Status status = Status.get(117)
        then:
            status // FIXME status cannot be found by ID
            status.user
            status.user.username == 'John Sno'
            status.text == 'Spreading some salt around Airdrie'
            status.engagements
            status.engagements.size() == 3
    }

Loading Data with JsonSlurper

{
    "id": 117,
    "created": "2018-02-28T07:44:35Z",
    "text": "Spreading some salt around Airdrie",
    "user": 1,
    "engagementsCount": 3,
    "engagements": [
      {
        "id": 616,
        "created": "2018-03-02T12:31:47Z",
        "user": 2
      },
      {
        "id": 618,
        "created": "2018-03-02T12:31:47Z",
        "user": 4
      },
      {
        "id": 617,
        "created": "2018-03-02T12:31:47Z",
        "user": 4
      }
    ]
}
[
  {
      "id": 1,
      "username": "John Sno"
  },
  {
      "id": 2,
      "username": "Ice Queen"
  }
]

Multiple JSON Sources Problem

DynamoDB

Relational Database

Engagement

Status

User

Multiple Persistence Stores Problem

    @Rule Dru dru = Dru.plan {
        from 'statuses.json', {
            map {
                to Status
            }
        }
    }

    void setup() {
        dru.load()
    }

    void 'test loaded'() {
        expect:
            Status.list().size() == 25
            User.list().size() == 13
            Engagement.list().size() == 133

        when:
            Status first = dru.findByTypeAndOriginalId(Status, 117)
        then:
            first.user
            first.user.username == 'John Sno'
            first.text == 'Spreading some salt around Airdrie'
            first.engagements
            first.engagements.size() == 3
    }

Loading Data with Dru

Condition not satisfied:

dru.report.empty
|   |      |
|   |      false
|   ============================================================================
|   ‖ TYPE     ‖ PROPERTY         ‖ PATH                            ‖ VALUE    ‖
|   ============================================================================
|   ‖ Status   | engagementsCount | statuses.json/.engagementsCount | 3        ‖
|   ============================================================================
com.agorapulse.dru.Dru@6165a030

	at gritter.StatusServiceSpec.test loaded(StatusServiceSpec.groovy:42)
    void 'all data handled'() {
        expect:
            dru.report.empty
    }

Source Data Usage Report

    @Rule Dru dru = Dru.plan {
        from 'statuses.json', {
            map {
                to Status, {
                    ignore 'engagementsCount'
                }
            }
        }
    }

Ignoring Data

    void 'get top statuses'() {
        given:
            DateFormat format = new SimpleDateFormat('yyyy-MM-dd')
        when:
            List<Status> statuses = service.findTopStatuses(
                    format.parse('2018-02-01'),
                    format.parse('2018-03-20'),
                    50
            )
        then:
            statuses
            statuses.size() == 19
        when:
            Status first = statuses[0]
        then:
            first.user
            first.user.username == 'Sprinkles'
            first.text == 'Refueling before leaving Larkhall'
            first.engagements
            first.engagements.size() == 11
    }

Service Unit Test

    static PreparedDataSet statuses = Dru.prepare {
        from 'statuses.json', {
            map {
                to Status, {
                    ignore 'engagementsCount'
                }
            }
        }
    }

    @Rule Dru dru = Dru.plan {
        include statuses
    }

Test Data Set Reuse

Dru

  • Reuse existing denormalised data
  • Mix & Match
    • POJO
    • GORM
    • DynamoDB
  • Formats
    • JSON
    • YAML
  • Easily extensible

Gru

How to test HTTP endpoints effectively?

Mind the Gap

Fast

Complex

DSL

Mocks

Slow

Deployment

URL Mappings

Interceptors

Controllers

Views

Gru

Integration

Unit

Isolation

    def index() {
        Map queryParameters = [
                max: params.int('max', 25),
                offset: params.int('offset', 0),
                sort: params.sort ?: 'created',
                order: params.order ?: 'desc'
        ]
        [statuses: Status.list(queryParameters)]
    }

Controller Action

    void 'test index'() {
        when:
            params.max = 10
            params.offset = 5

            def model = controller.index()

        then:
            model
            model.statuses
            model.statuses.size() == 10

        when:
            def status = model.statuses.first()
        then:
            status
            status.user
            status.user.username == 'Ready Spready Go'
            status.text == 'Grooming snow close to Kirkintilloch'
    }

Controller Unit Test (Action)

import gritter.Engagement
import gritter.Status

model {
	Status status
}

json g.render(status, [excludes: ['user', 'engagements']]) {
	user g.render(status.user)
    engagementsCount status.engagements?.size() ?: 0
    engagements g.render(status.engagements ?: [], [excludes: ['status', 'user']]) { Engagement engagement ->
        user g.render(engagement.user)
    }
}

JSON View

class StatusGsonSpec extends Specification implements JsonViewUnitTest {

    void 'test index rendered'() {
        when:
            Map<String, List<Status>> model = [statuses: [new Status(
                    user: new User(username: 'Vlad'),
                    text: 'Hi all!'
            )]]
            JsonRenderResult result = render(view: "/status/index", model: model)
        then:
            result.json instanceof List
            result.json.size() == 1
            result.json[0].user
            result.json[0].user.username == 'Vlad'
            result.json[0].text == 'Hi all!'
            result.json[0].engagements instanceof List
            result.json[0].engagementsCount == 0
    }

}

Json View Unit Test

    void 'test index rendered with json unit'() {
        when:
            Map<String, List<Status>> model = [statuses: [new Status(
                    user: new User(username: 'Vlad'),
                    text: 'Hi all!'
            )]]
            JsonRenderResult result = render(view: "/status/index", model: model)
        then:
            JsonFluentAssert.assertThatJson(result.jsonText).isEqualTo('''[
                {
                    "created" : "${json-unit.ignore}",
                    "text" : "Hi all!",
                    "user" : {
                        "username" : "Vlad"
                    },
                    "engagementsCount" : 0,
                    "engagements" : []
                }
            ]''')
    }

Unit Test with JsonUnit

class UrlMappingsSpec extends Specification implements UrlMappingsUnitTest<UrlMappings> {

    void setup() {
        mockController StatusController
    }

    void 'check forward mapping'() {
        expect:
            assertForwardUrlMapping('/status', controller: 'status', action: 'index')
    }

}

URL Mappings Unit Test

What was the goal?

    @Rule Dru dru = Dru.plan {
        include StatusServiceSpec.statuses
    }

    @Rule Gru gru = Gru.equip(Grails.steal(this)).prepare {
        include UrlMappings
    }

    void setup() {
        dru.load()
    }

    void 'get statuses'() {
        expect:
            gru.test {
                get '/status', {
                    params max: 10, offset: 5
                }

                expect {
                    json 'statuses.json'
                }
            }
    }

Gru Test

Condition failed with Exception:

gru.test { get '/status', { params max: 10, offset: 5 executes  controller.&index } expect { json 'statuses.json' } }
|   |
|   org.codehaus.groovy.runtime.InvokerInvocationException: java.lang.AssertionError: New fixture files were created: gritter/StatusControllerSpec/statuses.json. Please, run the test again to verify it is repeatable.
com.agorapulse.gru.Gru@311de37

	at gritter.StatusControllerSpec.get statuses(StatusControllerSpec.groovy:50)
Caused by: org.codehaus.groovy.runtime.InvokerInvocationException: java.lang.AssertionError: New fixture files were created: gritter/StatusControllerSpec/statuses.json. Please, run the test again to verify it is repeatable.
	... 1 more
Caused by: java.lang.AssertionError: New fixture files were created: gritter/StatusControllerSpec/statuses.json. 
Please, run the test again to verify it is repeatable.
	at com.agorapulse.gru.minions.AbstractContentMinion.doVerify(AbstractContentMinion.groovy:43)
	at com.agorapulse.gru.minions.AbstractMinion.verify(AbstractMinion.java:44)
	at com.agorapulse.gru.Squad.verify(Squad.java:117)
	at com.agorapulse.gru.Gru.verify(Gru.java:143)
	at com.agorapulse.gru.Gru.asBoolean(Gru.java:124)
	... 1 more

Autogenerated Fixture File

    def save(Status status) {
        User user = currentUser

        if (!user) {
            render status: UNAUTHORIZED
            return
        }

        status.user = user
        status.validate()

        if (status.hasErrors()) {
            respond status.errors
            return
        }

        status.save flush: true

        respond status, [status: CREATED, view: 'show']
    }

Save Action

class UserInterceptor {

    UserInterceptor() { matchAll() }

    boolean before() {
        String username = request.getHeader('User')

        if (username) {
            request.setAttribute('user', User.findByUsername(username))
        }

        return true
    }

}

Interceptor

    @Rule Gru gru = Gru.equip(Grails.steal(this)).prepare {
        include UrlMappings
        include UserInterceptor
    }


    void 'create status'() {
        expect:
            gru.test {
                post '/status', {
                    headers User: 'John Sno'
                    json 'newStatusRequest.json'

                }
                expect {
                    status CREATED
                    json 'newStatusResponse.json'
                }
            }
    }
{
  "text": "No Snow here, let's take a break!"
}

Gru Test with Interceptor

{
  "id": "${json-unit.matches:positiveIntegerString}",
  "created": "${json-unit.matches:isoDateNow}",
  "text": "No Snow here, let's take a break!",
  "user": {
    "id": "${json-unit.matches:positiveIntegerString}",
    "username": "John Sno"
  },
  "engagementsCount": 0,
  "engagements": []
}

JsonUnit Extra Matchers

@Integration @Rollback class StatusControllerIntegrationSpec extends Specification  {

    @Value('${local.server.port}') Integer serverPort

    @Rule Gru<Http> gru = Gru.equip(Http.steal(this))

    void setup() {
        final String serverUrl = "http://localhost:${serverPort}"
        gru.prepare {
            baseUri serverUrl
        }
    }

    void 'create status'() {
        expect:
            gru.test {
                post '/status', {
                    headers User: 'John Sno'
                    json 'newStatusRequest.json'

                }
                expect {
                    status CREATED
                    json 'newStatusResponse.json'
                }
            }
    }
}

Gru Test with HTTP

Gru

  • Groovy HTTP Testing Framework
  • Specification by Example
  • Sensible defaults
  • Built-in JsonUnit
  • Supports
    • HTTP
    • Grails Controllers
    • Spring MVC
    • AWS API Proxy (soon)
  • Easily extensible

Gru HTTP

  • Status assertions
  • Formats​ in & out
    • Plain text
    • JSON
    • HTML
  • Query parameters
  • Headers
  • Redirection

Gru Grails

  • Based on the new testing framework
  • Extends Gru HTTP with
    • URL Mappings
    • Interceptors
    • Model assertions
    • Forwarding
  • JSON views support
Made with Slides.com