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.
1 Answer 1
A suggestion would like to give, not sure how useful it would turn out for you.
- Separate out the controller level validations from Model level validations.
For validating the data for the users whether they have mentioned attributes or not, "comparable_attributes: [:id, :email, :first_name, :last_name]", mention such validation at Model level spec file.
For instance:
require 'rails_helper'
describe User do
#Tests for fields
it {is_expected.to respond_to :firstname}
it {is_expected.to respond_to :lastname}
it {is_expected.to respond_to :email}
#Tests for validations
describe 'validate lengths' do
it {is_expected.to validate_length_of(:email).is_at_most(255)}
it {is_expected.to validate_length_of(:firstname).is_at_most(255)}
it {is_expected.to validate_length_of(:lastname).is_at_most(255)}
end
describe 'validate presence' do
it {is_expected.to validate_presence_of :firstname }
it {is_expected.to validate_presence_of :lastname }
end
end
Create a user in the model with valid parameters and set it in the current_user and that same object can be used in the controller, where you can authenticate various controller request
-
\$\begingroup\$ Thanks for your reply. Besides request specs, the application also has model specs which test validations and other model behaviors. The
comparable_attributes
in the API tests serve to ensure that records which are retrieved, created or updated via API have the expected attribute values. Hence, it is not the same as testing the model. Regarding the creation of test data (e.g., a valid user object), I'm using Factory Girl. \$\endgroup\$BrunoF– BrunoF2017年07月19日 12:26:54 +00:00Commented Jul 19, 2017 at 12:26
Explore related questions
See similar questions with these tags.