Testing Grails with Dru & Gru
Vladimír Oraný
Test Facilitator @ Agorapulse
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
- Testing HTTP endpoints in Grails
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
Testing Grails with Dru & Gru
By musketyr
Testing Grails with Dru & Gru
- 1,949