Test-driven Rails APIs that stay documented
For the last few months most of my days have been about designing, building and optimizing APIs in Rails. Writing the endpoints is the easy part. The hard part is keeping three things aligned over time: what the API actually does, what the tests say it does, and what the documentation promises to the people consuming it (mobile devs, frontend devs, and me in three months).
This is the workflow I ended up with. Nothing revolutionary, but it works and I wish somebody had written it down for me earlier.
Start from the request, not the model
When people talk about rails api tdd they often start with model specs. I don't. For an API, the contract is the HTTP request and the JSON that comes back, so that's the first thing I write. Before any controller exists, I write a request spec describing the endpoint as the client will see it.
# spec/requests/api/v1/bookings_spec.rb
require 'rails_helper'
RSpec.describe 'Bookings API', type: :request do
let(:user) { create(:user) }
let(:headers) { auth_headers_for(user) }
describe 'POST /api/v1/bookings' do
it 'creates a booking and returns it' do
post '/api/v1/bookings',
params: { starts_at: '2018-03-01T10:00:00Z', service_id: 42 }.to_json,
headers: headers
expect(response).to have_http_status(201)
expect(json['starts_at']).to eq('2018-03-01T10:00:00Z')
end
it 'returns 422 when starts_at is missing' do
post '/api/v1/bookings', params: { service_id: 42 }.to_json, headers: headers
expect(response).to have_http_status(422)
expect(json['errors']).to include('starts_at')
end
end
endjson and auth_headers_for are tiny helpers in spec/support. The point is that the spec reads like a conversation with the client: I send this, I get that back. Writing it first forces me to decide the status codes, the error format and the field names before I get attached to an implementation. Most of the arguments I used to have with the mobile team were about exactly these details, discovered too late.
Then the usual loop: red, write the minimum controller and model code, green, refactor. Model and service specs come in when there's real logic worth isolating, not by default.
Docs generated from the specs
I've tried keeping a hand-written API doc (a wiki page, a Markdown file, a shared Google Doc). It is always wrong. Not because people are lazy, but because nobody remembers to update the doc when they rename a param on a Friday afternoon.
My answer to api documentation rails problems is: don't write the docs, generate them from the tests. If the tests pass, the docs are true. For this I use the rspec_api_documentation gem. It adds a small DSL on top of RSpec where you describe parameters and examples, and it records real requests and responses while the specs run.
# spec/acceptance/bookings_spec.rb
require 'rails_helper'
require 'rspec_api_documentation/dsl'
resource 'Bookings' do
header 'Content-Type', 'application/json'
header 'Authorization', :auth_token
let(:user) { create(:user) }
let(:auth_token) { "Bearer #{token_for(user)}" }
post '/api/v1/bookings' do
parameter :starts_at, 'ISO 8601 start time', required: true
parameter :service_id, 'ID of the booked service', required: true
let(:starts_at) { '2018-03-01T10:00:00Z' }
let(:service_id) { create(:service).id }
let(:raw_post) { params.to_json }
example 'Create a booking' do
do_request
expect(status).to eq(201)
end
end
endThen:
bundle exec rake docs:generateand you get HTML (or JSON, or API Blueprint if you prefer) with every endpoint, its parameters and real example payloads. Not made-up examples: the actual response your code returned during the test run.
A few things I learned the hard way:
- Keep the acceptance specs focused on the happy path plus the one or two errors a client really needs to handle. Edge cases go in the request specs, otherwise the docs become a wall of noise.
- Write parameter descriptions like you're explaining them to someone who has never seen the codebase. "ID" is not a description.
- Generate the docs in CI. If docs generation is a manual step, it will not happen. Trust me.
Is there some duplication between request specs and acceptance specs? Yes, a bit. I'm honestly not sure yet if it's worth merging them into one layer. For now I prefer the separation: one set of tests to be thorough, one set to be readable.
Docker so "works on my machine" means something
rspec api testing only helps if everyone runs the same tests against the same stack. The classic failure: tests green on my laptop with Postgres 9.6, red on a colleague's with a different version and a different locale. Half a morning lost.
For docker rails development I keep it boring: one Dockerfile for the app, one docker-compose.yml for the app plus its dependencies.
version: '3'
services:
db:
image: postgres:10
volumes:
- pgdata:/var/lib/postgresql/data
web:
build: .
command: bundle exec rails s -b 0.0.0.0
volumes:
- .:/app
- bundle:/usr/local/bundle
ports:
- "3000:3000"
depends_on:
- db
environment:
DATABASE_URL: postgres://postgres@db/app_development
volumes:
pgdata:
bundle:The bundle volume is the detail that saves the most time: gems survive container rebuilds, so you don't reinstall everything every time you touch the Dockerfile. Running the suite becomes:
docker-compose run --rm -e RAILS_ENV=test web bundle exec rspecSame command on every laptop and on CI. New developer onboarding goes from "let me help you install Postgres and the right Ruby" to docker-compose up. On macOS the mounted volume is a bit slow, and I still run single specs locally outside Docker when I'm iterating fast. Not perfect, but the full suite always runs in the container before pushing.
The combination is what makes it work. Tests first means the contract is decided on purpose. Docs from tests means the contract is always written down correctly. Docker means everyone checks it against the same thing. Next thing I want to try is versioning the generated docs together with the API versions, so clients on v1 don't get confused by v2 changes. I'll write about it if it goes well (or especially if it doesn't).