Tests are documentation

Years ago, I was asked why I spent so much time writing tests - my answer: "tests are documentation".

At the simplest level, that's true - you read the tests and you can see examples of how the code in question works.

But it also works at a higher level, if your test framework supports it,

RSpec has a bad reputation amongst many developers - it's verbose, the code can be quite complex to debug when the framework itself goes wrong and some people just hate the way it looks - with its describe and it statements.

I've always found that when I use RSpec I write better code.

I think this is exactly because of how it looks - by writing out a skeleton set of describe and it statements, I am effectively thinking through what I want the thing to do, in ruby-flavoured English, before I actually start working on it. I don't want to assert that a value is x, I expect to see some results - which is a semantic difference - but then, writing is all about semantics.

This can then be taken a step further. If the describe blocks and it statements are an outline of what I want the app to do then they actually become the documentation. Even better, they become documentation that is almost trivial to maintain and keep up to date as the system changes.

So for a recent project, which has a HTTP/JSON API, I used the RSpec tests themselves as the developer documentation.

The documentation looks like this:


Log in
  Generating an API Token.

  Post to /api/logins with your email address and password and if correct, this will return an API token (as the `reference` field).  You can then use the API token in subsequent API calls to identify yourself.  The API token will expire if it is not used for 28 days, at which point you will need to authenticate with your email address and password to obtain a new one.  
  
  To use the API Token, use HTTP Basic Authentication, passing the token as the "password" field.  (The "username" field is ignored so just pass a dummy value).  
  
  POST /api/logins
  PARAMS: { "email": "email@example.com", "password": "password123" }
  RESPONSE: { "reference": API_TOKEN }
    succeeds given a correct email address and password - status 200
    fails given an incorrect email address - status 401
    fails given an incorrect password - status 401

The RSpec itself is a "request" spec, which Rails calls an "integration" test - RSpec uses slightly different terminology to the in-built Rails testing tools, which is annoying, but then everyone seems to refer to all types of tests with different words.

A request spec (or integration test) sends HTTP requests to the Rails app - which goes through the middleware stack, the router, the controller, views and models, and eventually returns a response to the fake browser. This is similar to what Rails calls a "system" test - however, the difference is system tests are designed to simulate a human using the app. Hence they use Capybara and run an actual browser instance (headless Chrome or Firefox) to perform the tests. That way you can also trigger any Javascript on your pages in the same way a real user would.

In our case, as this is just an API test, we don't need that level of sophistication, so our request specs use Rack::Test, which generates the HTTP requests and deals with the HTTP responses without the overhead of a full browser. (Plus - as soon as Javascript gets involved, tests become brittle due to timing errors as the various processes communicate with each other).

The actual spec itself looks like this:

require "rails_helper"

RSpec.describe "Log in", type: :request do
  include Fixtures

  title = <<-TITLE

  Generating an API Token.

  Post to /api/logins with your email address and password and if correct, this will return an API token (as the `reference` field).  You can then use the API token in subsequent API calls to identify yourself.  The API token will expire if it is not used for 28 days (but this may change), at which point you will need to authenticate with your email address and password to obtain a new one.  
  
  To use the API Token, use HTTP Basic Authentication, passing the token as the "password" field.  (The "username" field is ignored so just pass a dummy value).  
  
  POST /api/logins
  PARAMS: { email: "email@example.com", password: "password123" }
  RESPONSE: { "reference": API_TOKEN }
  TITLE
  
  describe title do
    it "succeeds given a correct email address and password - status 200" do
      post "/api/logins", params: { email: me.email, password: "password123" }
      expect(response.status).to eq 200
      reference = JSON.parse(response.body)["reference"]
      expect(reference).to eq me.api_token
    end

    it "fails given an incorrect email address - status 401" do
      post "/api/logins", params: { email: "nobody@nowhere.com", password: "password123" }
      expect(response.status).to eq 401
      reference = JSON.parse(response.body)["reference"]
      expect(reference).to be_nil
    end

    it "fails given an incorrect password - status 401" do
      post "/api/logins", params: { email: me.email, password: "notthepassword" }
      expect(response.status).to eq 401
      reference = JSON.parse(response.body)["reference"]
      expect(reference).to be_nil
    end
  end
end

I use one spec file to group related API calls together (for example, in this app I have "login_spec", "generating_reports_spec", "taking_an_assessment_spec"). Then within each spec file, I have a separate describe block for each individual API call - one per route within the Rails app. So in this case, we have a "login_spec" that tests a single API call - POST /api/logins.

We use Ruby's heredoc syntax (<<-TITLE ... TITLE) to generate a multi-line title that describes the route that we're about to test. This title follows a standard format:

  • a human-readable title
  • a brief explanation of what's going on
  • the URI we are describing
  • the expected parameters
  • the expected response

Within the describe block, we then test the common scenarios when calling this API. In this case:

  • what happens if someone logs in successfully?
  • what happens if they provide a non-existent email?
  • what happens if they provide the wrong password?

Each of these it blocks includes a human-friendly description of the scenario, including the expected HTTP status code.

Finally, I have a simple shell script - bin/docs - that generates the RSpec in "documentation" format and then copies it to the app's public directory. This script is then called by my deployment system, so before every new version, the tests are run, the documentation file updated and then copied to the live system.

bin/rspec -I spec/requests --format=documentation > public/api.routes.txt

The danger of documentation is that it gets out of date but this way, if I'm making a change to the system, I'm actually writing and editing the documentation at the same time as I'm writing the code.

Rahoul Baruah

Rahoul Baruah

Rubyist since 1.8.6. Freelancer since 2007, dedicated to building incredible, low-cost, bespoke software for tiny businesses. Also CTO at Collabor8Online.
Leeds, England