4
\$\begingroup\$

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.

asked Jul 10, 2017 at 21:25
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

A suggestion would like to give, not sure how useful it would turn out for you.

  1. 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

answered Jul 19, 2017 at 3:39
\$\endgroup\$
1
  • \$\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\$ Commented Jul 19, 2017 at 12:26

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.