I have written the following shared examples which are used in multiple request specs to test a namespaced RESTful JSON API.
Application details: Rails 4.2, RSpec 3.5, Devise for authentication and Pundit for authorization. Authorization policies are tested separarely as discussed in this post.
I would greatly appreciate your feedback and suggestions on how to improve the specs (e.g., efficiency, readability, maintainability, DRYness).
Here are the shared examples:
RSpec.shared_examples "a RESTful JSON API",
http_error_instead_of_exception: true do |controller_class:,
resource_path:,
comparable_attributes:|
def self.controller_has_action?(controller_class, action)
controller_class.action_methods.include?(action.to_s)
end
# Ensure authorization (Pundit gem) is enforced
def mock_authorization(authorized: false)
# Avoid Pundit::AuthorizationNotPerformedError when using "after_action
# :verify_authorized". Use "allow" and not "expect" as #verify_authorized is
# only called when we do not raise Pundit::NotAuthorizedError.
allow_any_instance_of(Api::V1::BaseApiController).to \
receive(:verify_authorized)
expectation = expect_any_instance_of(Api::V1::BaseApiController).to \
receive(:authorize)
# Simulate a "not authorized" scenario
expectation.and_raise(Pundit::NotAuthorizedError) if !authorized
end
resource_singular = resource_path.split("/").last.singularize.to_sym
resource_plural = resource_path.split("/").last.to_sym
before(:each) { login_admin }
let(:record) { FactoryGirl.create(resource_singular) }
let(:records) { FactoryGirl.create_pair(resource_singular) }
# Models that validate the presence of associated records require some
# hacking in the factory to include associations in #attributes_for
let(:valid_attributes) { FactoryGirl.attributes_for(resource_singular) }
# All factories must have a trait called :invalid
let(:invalid_attributes) do
FactoryGirl.attributes_for(resource_singular, :invalid)
end
let(:response_json) { JSON.parse(response.body) }
describe "GET #{resource_path} (#index)",
if: controller_has_action?(controller_class, :index) do
before(:each) do
# Test data is lazily created. Here we must force it to be created.
records
end
it "requires authentication" do
logout_example
get resource_path
expect(response).to require_login_api
end
it "enforces authorization" do
expect_any_instance_of(Api::V1::BaseApiController).to \
receive(:policy_scope).and_call_original
get resource_path
end
it "returns a 'OK' (200) HTTP status code" do
get resource_path
expect(response).to have_http_status(200)
end
it "returns all #{resource_plural}" do
get resource_path
# When testing the User model, a user created by the Devise login helper
# increases the expected record count to 3.
expected_count = resource_singular == :user ? 3 : 2
expect(response_json.size).to eq(expected_count)
end
end
describe "GET #{resource_path}/:id (#show)",
if: controller_has_action?(controller_class, :show) do
it "requires authentication" do
logout_example
get "#{resource_path}/#{record.id}"
expect(response).to require_login_api
end
it "enforces authorization" do
mock_authorization(authorized: false)
get "#{resource_path}/#{record.id}"
expect(response).to have_http_status(403)
end
context "with a valid #{resource_singular} ID" do
before(:each) do
get "#{resource_path}/#{record.id}"
end
it "returns a 'OK' (200) HTTP status code" do
expect(response).to have_http_status(200)
end
it "returns the requested #{resource_singular}" do
expect(response_json).to include(
record.attributes.slice(comparable_attributes))
end
end
context "with an invalid #{resource_singular} ID" do
before(:each) { get "#{resource_path}/9999" }
it "returns a 'not found' (404) status code" do
expect(response).to have_http_status(404)
end
end
end
describe "POST #{resource_path} (#create)",
if: controller_has_action?(controller_class, :create) do
it "requires authentication" do
logout_example
post resource_path, { resource_singular => valid_attributes }
expect(response).to require_login_api
end
it "enforces authorization" do
mock_authorization(authorized: false)
post resource_path, { resource_singular => valid_attributes }
expect(response).to have_http_status(403)
end
context "with valid attributes" do
before(:each) do
post resource_path, { resource_singular => valid_attributes }
end
it "returns a 'created' (201) HTTP status code" do
expect(response).to have_http_status(201)
end
it "returns the created #{resource_singular}" do
expect(response_json).to include(
record.attributes.slice(comparable_attributes))
end
end
context "with invalid attributes" do
before(:each) do
post resource_path, { resource_singular => invalid_attributes }
end
it "returns a 'unprocessable entity' (422) HTTP status code" do
expect(response).to have_http_status(422)
end
end
end
describe "PATCH #{resource_path}/:id (#update)",
if: controller_has_action?(controller_class, :update) do
it "requires authentication" do
logout_example
patch "#{resource_path}/#{record.id}",
{ resource_singular => valid_attributes }
expect(response).to require_login_api
end
it "enforces authorization" do
mock_authorization(authorized: false)
patch "#{resource_path}/#{record.id}",
{ resource_singular => valid_attributes }
expect(response).to have_http_status(403)
end
context "with valid attributes" do
before(:each) do
patch "#{resource_path}/#{record.id}",
{ resource_singular => valid_attributes }
end
it "returns a 'OK' (200) HTTP status code" do
expect(response).to have_http_status(200)
end
it "returns the updated #{resource_singular}" do
record.reload
expect(response_json).to include(
valid_attributes.slice(comparable_attributes))
end
end
context "with invalid attributes" do
before(:each) do
patch "#{resource_path}/#{record.id}",
{ resource_singular => invalid_attributes }
end
it "returns an 'unprocessable entity' (422) status code" do
expect(response).to have_http_status(422)
end
end
end
describe "DELETE #{resource_path}/:id (#destroy)",
if: controller_has_action?(controller_class, :destroy) do
it "requires authentication" do
logout_example
delete "#{resource_path}/#{record.id}"
expect(response).to require_login_api
end
it "enforces authorization" do
mock_authorization(authorized: false)
delete "#{resource_path}/#{record.id}"
expect(response).to have_http_status(403)
end
it "ensures the #{resource_singular} no longer exists" do
delete "#{resource_path}/#{record.id}"
# When testing the "user" resource, Devise unexpectedly logs out
# (resulting in 401 to any further requests) after *any* user is deleted.
login_admin if resource_singular == :user
get "#{resource_path}/#{record.id}"
expect(response).to have_http_status(404)
end
it "returns a 'no content' (204) status code" do
delete "#{resource_path}/#{record.id}"
expect(response).to have_http_status(204)
end
end
end
... and this is a sample request spec:
# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users API", :type => :request do
it_behaves_like "a RESTful JSON API",
controller_class: Api::V1::UsersController,
resource_path: "/api/v1/users",
comparable_attributes: [:id, :email, :first_name, :last_name]
end
Thanks in advance.