Test Driven Development of a Versioned, RESTful API
Appropriate for use with RESTful clients such as Ember-Data & RestKit
Using
ActiveModelSerializers - Serialization in line with the jsonapi.org specMiniTest & Rack::Test - API Integration tests
FactoryGirl - Factory Generation for tests
Source at https://github.com/robj/widgetapi
Application Setup
Create a new rails application.
$ rails new widgetapi
Add the active_model_serializers gem and run bundler.
#Gemfile
gem 'active_model_serializers
$ bundle
Model
Generate a Widget model and migrate the DB
$ rails g model widget name:string supplier:string cost:integer
$ bundle exec rake db:migrate
$ bundle exec rake db:test:prepare
API Versioning
Ensure Rails autoloads /lib/*
#config/application.rb
module Widgetapi
class Application < Rails::Application
config.autoload_paths += %W(#{config.root}/lib)
...
Add ApiConstraints for header based version control.
(see http://jes.al/2013/10/architecting-restful-rails-4-api)
If no version is explicitly requested, fall back to V1.
#lib/api_constraints.rb
class ApiConstraints
def initialize(options)
@version = options[:version]
@default = options[:default]
end
def matches?(req)
@default || req.headers['Accept'].include?("application/vnd.myapp.v#{@version}")
end
end
Routing
Create a widget resources route under the API namespace.
#config/routes.rb
namespace :api, defaults: {format: :json} do
scope module: :v1, constraints: ApiConstraints.new(version: 1, default: :true) do
resources :widgets
end
end
Serializer
rails g serializer widget
Add appropriate fields to the widget serializer.
#app/serializers/widget_serializer.rb
class WidgetSerializer < ActiveModel::Serializer
attributes :id, :name, :supplier, :cost
end
Controller
#app/controllers/api/v1/widgets_controller.rb
class Api::V1::WidgetsController < ApplicationController
end
MiniTest Setup
Add appropriate Gems to the Test environment and run bundler.
#Gemfile
group :development, :test do
gem 'minitest'
gem "rack-test", require: "rack/test"
gem 'database_cleaner'
gem 'factory_girl'
gem 'random-word'
end
$ bundle
Setup a helper for minitest.
#test/minitest_helper.rb
ENV["RAILS_ENV"] = "test"
require File.expand_path('../../config/environment', __FILE__)
require 'minitest/autorun'
require "active_support/testing/setup_and_teardown"
require 'factory_girl'
require 'database_cleaner'
require "rack/test"
FactoryGirl.find_definitions
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.start
class MiniTest::Spec
include Rails.application.routes.url_helpers
include Rack::Test::Methods
before :each do
DatabaseCleaner.clean
end
after :each do
end
def last_response_json
JSON.parse(last_response.body)
end
end
Integration Test
Create a skeleton test for the Widget API.
#test/integration/widget_api_integration_test.rb
require "minitest_helper"
def app
Rails.application
end
describe "API Widget integration" do
it does "nothing" do
end
end
Ensure our test environment works correctly with a blank test.
$ ruby -Itest test/integration/widget_api_integration_test.rb
Factory
Create a Widget factory
#test/factories/widget.rb
FactoryGirl.define do
factory :widget do
name {"#{RandomWord.adjs.next}"}
supplier {"#{RandomWord.adjs.next} Supplier"}
cost {(1..99).to_a.sample}
end
end
Index action
Controller
class Api::V1::WidgetsController < ApplicationController
def index
widgets = Widget.all
render json: widgets
end
...
Integration Test
it "should list widgets index" do
widget_creation_count = 22
(1..widget_creation_count).each do
FactoryGirl.create(:widget)
end
get '/api/widgets'
assert last_response.successful?
assert last_response_json['widgets'].count.must_equal widget_creation_count
end
Show action
Controller
def show
widget = Widget.find(params[:id])
if widget
render json: widget
else
head :not_found
end
end
Integration Test
it "should get a widget with expected keys" do
widget = FactoryGirl.create(:widget)
get "/api/widgets/#{widget.id}"
assert last_response.successful?
assert last_response_json['widget'].has_key?('id').must_equal true
assert last_response_json['widget'].has_key?('name').must_equal true
assert last_response_json['widget'].has_key?('supplier').must_equal true
assert last_response_json['widget'].has_key?('cost').must_equal true
end
Create action
Controller- Uses strong_params to prevent mass assignment attacks.
def create
widget = Widget.new(widgets_params_create)
if widget.save
render json: widget
else
head :internal_server_error
end
end
private
def widgets_params_create
permitable_params = [:name, :supplier, :cost]
params.require(:widget).permit(permitable_params)
end
def widgets_params_update
permitable_params = [:supplier, :cost]
params.require(:widget).permit(permitable_params)
end
Integration Test
it "should create a new widget" do
widget = { name: 'widgee', supplier: 'widgsoft', cost: 32 }
post '/api/widgets', {widget: widget}
assert last_response.successful?
assert last_response_json['widget']['name'].must_equal 'widgee'
end
Update action
Controller
def update
widget = Widget.find(params[:id])
if widget.update_attributes(widgets_params_update)
render json: widget
else
head :internal_server_error
end
end
Integration Test
it "should get and update a widget" do
widget = FactoryGirl.create(:widget)
get '/api/widgets'
assert last_response.successful?
widget_json = last_response_json['widgets'].first
widget_id = widget_json['id']
widget_json['supplier'] = 'supplierX'
put "/api/widgets/#{widget_id}", {widget: widget_json}
assert last_response.successful?
assert last_response_json['widget']['supplier'].must_equal 'supplierX'
end
it "should get and update a widget, ensuring strong_params prevents name being updated" do
widget = FactoryGirl.create(:widget)
get '/api/widgets'
assert last_response.successful?
widget_json = last_response_json['widgets'].first
widget_id = widget_json['id']
widget_original_name = widget_json['name']
widget_json['name'] = 'nameX'
put "/api/widgets/#{widget_id}", {widget: widget_json}
assert last_response.successful?
assert last_response_json['widget']['name'].must_equal widget_original_name
end
Destroy action
Controller- Demonstrates use of auth_token to prevent unauthorized deletion.
def destroy
return head :unauthorized unless (params.has_key?('auth_token') && params[:auth_token] == 's3cr3t')
widget = Widget.find(params[:id])
if widget.delete
head :no_content
else
head :internal_server_error
end
end
Integration Test
it "should get and delete a widget if authorized" do
auth_token = 's3cr3t'
widget = FactoryGirl.create(:widget)
get '/api/widgets'
assert last_response.successful?
widget_json = last_response_json['widgets'].first
widget_id = widget_json['id']
delete "/api/widgets/#{widget_id}", {auth_token: 'incorrect'}
assert last_response.client_error?
assert last_response.status.must_equal 401 #unauthorized
delete "/api/widgets/#{widget_id}", {auth_token: auth_token}
assert last_response.successful?
assert last_response.status.must_equal 204 #no_content
end