My third post on doing RESTish JSON APIs in Rails is about testing the API. I'm assuming the network API should remain stable, not have fields added or removed without consideration (and ideally, documentation), especially if there are independently-written clients that use the server's API. So normal Rails "functional" testing is not enough here.
"Functional" testing is in scare quotes because it's got a specific meaning to Rails, not a generally-accepted meaning to programmers. It is how Rails defaults encourage programmers to test the actions of their controller classes, which is to say the results of their Web request handling (e.g. in the Rails guide, especially the section on testing views).
It gets pretty tedious to assert all the things are in a JSON response that should be:
assert_response :success data = JSON.parse(@response.body)["data"] assert_not_nil @data["comments"] assert_equal 1, @data["comments"].length assert_equal "The User", @data["comments"][0]["display_name"] assert_equal "the comment text", @data["comments"][0]["comment_text"]
Worse, this only tests that things that are tested for are there, not that new things don't creep in. It's pretty easy to modify code in a way that passes what looked like reasonable functional tests, but breaks the client.
While working on improving our testing for API responses, I've found a few things are important to me.
- The tests should show what an entire response ought to look like (aids readability)
- The tests should flag things that aren't supposed to be there as bugs
- The test responses should be templates, so that variables can vary
- The output of a test should show the difference, so I don't have to wade through pages of desired output and pages of actual output
- Cucumber to describe the request, the response status and the response body
- Custom cucumber step definitions to let the step define the entire response body as a Ruby Hash
- A custom template "Wildcard" object to let variables vary within the response body
- Rails feature to compare hashes and find diff, to produce readable output
Feature: Commenting Background: Given the following post exists: | id | 1234 | And the following comment exists: | id | 882281 | | post_id | 1234 | | comment_text | "O gentle son, upon the heat and flame of thy distemper sprinkle cool patience." | | user_id | 101 | Scenario: Retrieve a comment When I GET "/posts/1234/comments/882281" with a user session Then the response should have the following comment: """ { "comment_text"=> "O gentle son, upon the heat and flame of thy distemper sprinkle cool patience.", "created_at"=> Wildcard.new(String), "updated_at"=> Wildcard.new(String), "creator" => { user_id => 101, handle => "gertrude", display_name => "Queen Gertrude", number_comments => Wildcard.new(Fixnum) }, "post_url" => Wildcard.new(String) } """
The Wildcard class looks like this:
class Wildcard def initialize(must_be_class=Object) @must_be_class = must_be_class end def ==(other) other.class == @must_be_class end end
And the Cucumber step that implements "the response should have the following comment" looks like this:
Then /^the response should have the following ([^"]*):$/ do |element, string| the_hash = eval(string) response_data = JSON.parse(@response.body)["data"][element] assert_equal({}, response_data.diff(the_hash)) end
Note, what this means is that our API responses have "data" as the top element, with a "comment" element inside -- that part is asserted to be there, so that the contents of the comment can be compared with the Rails hash diff method. If the comment element in the response had an extra child, this would produce an error with the extra child in the diff. If the comment was missing a child, this would produce an error with the extra child in the diff. And if the comment has a child with a different value than what's in the template, this would produce an error with the returned value in the diff.
I realize this is not Behavior Driven Development (BDD). One can use Cucumber to do BDD, but the two are not inextricably linked. And our intent here is not to do BDD but to maintain a stable API. I think this works.