5
\$\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.

\$\endgroup\$
0
\$\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

\$\endgroup\$
  • \$\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\$ – BrunoFacca Jul 19 '17 at 12:26

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.