Code for this blog can be found on github at
https://github.com/galapagosfinch

Sunday, June 26, 2011

Grails Controllers and REST, part 3

In the previous part of this series, I talked about using POST, PUT, and DELETE to insert, update, and delete Cows into our app. In this part, we'll actually figure out if this thing works. We can do manual testing from the command line via curl, and we can also do automated testing using the Grails functional-test plugin.



First let's take a look at manual testing. I'm using Cygwin, with curl installed. After firing up the application, I will try a POST to see if I can insert something.
curl -i -H "Content-Type: application/json" -X POST -d "breed=Guernsey&color=Grey&legs=4" http://localhost:8080/moo/rest/cow/list

And what pops out?
[{"arguments":["legs","com.springminutes.example.Cow"],"bindingFailure":false,"class":"org.springframework.validation.FieldError","code":"nullable","codes":["com.springminutes.example.Cow.legs.nullable.error.com.springminutes.example.Cow.legs","com.springminutes.example.Cow.legs.nullable.error.legs","com.springminutes.example.Cow.legs.nullable.error.java.lang.Long","com.springminutes.example.Cow.legs.nullable.error","cow.legs.nullable.error.com.springminutes.example.Cow.legs","cow.legs.nullable.error.legs","cow.legs.nullable.error.java.lang.Long","cow.legs.nullable.error","com.springminutes.example.Cow.legs.nullable.com.springminutes.example.Cow.legs","com.springminutes.example.Cow.legs.nullable.legs","com.springminutes.example.Cow.legs.nullable.java.lang.Long","com.springminutes.example.Cow.legs.nullable","cow.legs.nullable.com.springminutes.example.Cow.legs","cow.legs.nullable.legs","cow.legs.nullable.java.lang.Long","cow.legs.nullable","nullable.com.springminutes.example.Cow.legs","nullable.legs","nullable.java.lang.Long","nullable"],"defaultMessage":"Property [{0}] of class [{1}] cannot be null","field":"legs","objectName":"com.springminutes.example.Cow","rejectedValue":null}]

Whoops, looks like I made a mistake. (Boy, that error bundle looks pretty bad. What would you return for a validation failure? Any suggestions?) I *think* what I'm seeing is that the data isn't getting through? Hmmm... oh, that's silly. I'm saying "application/json" but passing in form-encoded data! Let's try again.

Let's put the data in a file, rather than trying to clutter the command line:
$ cat data.json
{"breed":"Holstein","color":"Grey","legs":4}

Now, the reattempt:
$ curl --verbose --header "Content-type: application/json" --request POST --data @data.json http://localhost:8080/moo/rest/cow/list
* About to connect() to localhost port 8080 (#0)
*   Trying ::1... connected
* Connected to localhost (::1) port 8080 (#0)
> POST /moo/rest/cow/list HTTP/1.1
> User-Agent: curl/7.20.1 (i686-pc-cygwin) libcurl/7.20.1 OpenSSL/0.9.8r zlib/1.2.5 libidn/1.18 libssh2/1.2.5
> Host: localhost:8080
> Accept: */*
> Content-type: application/json
> Content-Length: 44
>
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Fri, 10 Jun 2011 02:06:51 GMT
<
* Connection #0 to host localhost left intact
* Closing connection #0
{"class":"com.springminutes.example.Cow","id":1,"breed":{"enumType":"com.springminutes.example.Breed","name":"Holstein"},"color":"Grey","legs":4}

Looks good! But do we really want to repeatedly run commands in order to test the app? As we know, manual testing requires *manual* effort for all developers- not just the one who's adding the feature, but also the maintainers. That's why automated testing is so powerful. It helps guarantee that how the app works now is how it will work in the future. So, let's do something useful and create some tests.

In order to test everything, including the UrlMappings, we're going to have to do some functional tests so we can have the entire stack available. We'll accomplish this using the Grails functional-test plugin. I've installed it using the old-style grails command:
grails install-plugin functional-test
(Maybe we can come back and talk about the new-style plugin dependency method using BuildConfig.)

Now, we create a class under the test/functional directory:
package com.springminutes.example

/**
 * Functional test for RESTful methods of CowController
 */
class CowControllerFunctionalTests extends functionaltestplugin.FunctionalTestCase {
}

Let's start with the GET commands:
void testGetList() {
   (new Cow(breed: Breed.Guernsey, color: 'yellow', legs: 3)).save()
   (new Cow(breed: Breed.Holstein, color: 'blue', legs: 4)).save()
   assertEquals 2, Cow.count()

   get('http://localhost:8080/moo/rest/cow/list') {
      headers['Content-Type'] = 'application/json'
   }
   assertStatus 200

   assertContentContains "Guernsey"
   assertContentContains "Holstein"
}

Lines 2 and 3 create two Cows in our newly-started app. Line 4 assures us that those are the only instances. Line 6-8 execute a GET at the url http://localhost:8080/moo/rest/cow/list, specifying a Content-Type header for JSON data. Line 9 asserts that the status should be 200, and lines 11 and 12 assert that we see the words both "Guernsey" and "Holstein" in the output.

We run it, and it passes. But look at the log: we have an exception there, something about parsing the JSON. Why is that? Well, looks like we've found a bug. Remember last time, when we added parseRequest to the UrlMappings? Here's a side effect - the GET commands, which do not have a payload, trigger an exception in the log. The method still worked, but we see an exception. We're not going to solve that right now.

On to the next test:
void testGetItem() {
   def originalCount = Cow.count()
   assertTrue originalCount >= 2 // should contain the entries from testGetList

   assertEquals Breed.Guernsey, Cow.get(1).breed
   get('http://localhost:8080/moo/rest/cow/element/1.json')
   assertStatus 200
   assertContentContains "Guernsey"

   assertEquals Breed.Holstein, Cow.get(2).breed
   get('http://localhost:8080/moo/rest/cow/element/2.json')
   assertStatus 200
   assertContentContains "Holstein"
}

Line 2 figures out many Cows are already in the database. Why? Well, here's the first lesson about functional tests - since they are executed outside the container, they aren't rolled back when you are finished. That means every test builds on the one in front of it. Your functional tests will either need to clean up after themselves (which I'm choosing not to do) or will have to take extra-special care to know the state of the application before the testing begins. That's what I do (kinda) in lines 3, 5, and 10 - assert my expectations about what I'm about to test for. Line 5 insures we know the breed of Cow #1, line 6 gets that cow via the REST URI, then we assert the status code (always) and assert that the content contains the correct breed. Lines 10-13 repeat this process for Cow #2.

On to POST:
void testInsert() {
   def originalCount = Cow.count()

   post('http://localhost:8080/moo/rest/cow/list') {
      headers['Accept'] = 'application/json'
      body {"""
      {"breed": "Guernsey", "color":"blue", "legs":"1"}
      """
      }
   }
   assertStatus 200
   assertEquals originalCount + 1, Cow.count()
   assertContentContains('"legs":1')
   assertContentContains('"color":"blue"')
}

Here we see something new, in lines 6-9: we provide a body for the POST. Both POST and PUT require a payload, and the functional test plugin makes that easy.

Next, we test the update method:
void testUpdate() {
   def originalCount = Cow.count()
   def cow1 = Cow.get(1)
   assertEquals Breed.Guernsey, cow1.breed
   assertEquals "yellow", cow1.color

   put('http://localhost:8080/moo/rest/cow/element/1') {
     headers['Accept'] = 'application/json'
     body {"""
     {"breed": "TexasLonghorn", "color":"teal", "legs":"1"}
     """
     }
   }
   assertStatus 200
   assertEquals originalCount, Cow.count()
   cow1.refresh() // reload from database; row changed because of call to put()
   assertEquals Breed.TexasLonghorn, cow1.breed
   assertEquals "teal", cow1.color
}

Notice line 16. Because we are executing outside the application, in a separate transaction, we must force a reload of our Cow in order to see the changes. Without the refresh, we will pull the Cow out of Hibernate session that was started when we did our pre-test assertions in lines 3-5. There are other ways to avoid this- the important point is to remember that the test is executing outside the current transaction.

Finally, the delete:
void testDelete() {
   def originalCount = Cow.count()
   assertTrue Cow.exists(1)
   delete('http://localhost:8080/moo/rest/cow/element/1')
   assertStatus 200
   assertContentContains "Cow 1 deleted"
   assertEquals originalCount - 1, Cow.count()
   assertFalse Cow.exists(1)
}

Here, I'm relying on a behavior of Hibernate in line 8 - the fact that the exists method will always go back to the database to verify existence. We don't have to worry about the Hibernate session.

So, what have we found? Well, the methods work, but we have that nagging parseRequest bug. And we still haven't created functional tests for the error scenarios. But this is a pretty good start at getting the happy path in place, and seeing how we can build out functional tests using the Grails plugin.

No comments:

Post a Comment