3
\$\begingroup\$

The following code is a controller spec used in a Rails 4.2 application with RSpec 3.5. I would greatly appreciate your suggestions on how to improve it (e.g., efficiency, readability, maintainability, DRYness).

# spec/controllers/authors_controller_spec.rb
require "rails_helper"
RSpec.describe AuthorsController, :focus, :type => :controller do
 login_admin
 let(:author) { FactoryGirl.create(:author) }
 let(:valid_attributes) do
 # The Author model validates the presence of associated records.
 # attributes_for does no create associated records by default.
 FactoryGirl.build(:author, name: "New name").attributes.symbolize_keys.
 except(:id, :slug, :created_at, :updated_at)
 end
 let(:invalid_attributes) { valid_attributes.update(name: nil) }
 describe "GET #index" do
 it "requires login" do
 sign_out login_user
 get :index
 expect(response).to require_login
 end
 it "enforces authorization" do
 get :index
 expect(controller).to enforce_authorization
 end
 it "populates an array of all authors" do
 authors = FactoryGirl.create_pair('author')
 get :index
 expect(assigns(:authors)).to match_array(authors)
 end
 end
 describe "GET #show" do
 it "requires login" do
 sign_out login_user
 get :show, id: author
 expect(response).to require_login
 end
 it "enforces authorization" do
 get :show, id: author
 expect(controller).to enforce_authorization
 end
 it "assigns the requested author to @author" do
 get :show, id: author
 expect(assigns(:author)).to eq(author)
 end
 end
 describe "GET #new" do
 it "requires login" do
 sign_out login_user
 get :new
 expect(response).to require_login
 end
 it "enforces authorization" do
 get :new
 expect(controller).to enforce_authorization
 end
 it "assigns a new Author to @author" do
 get :new
 expect(assigns(:author)).to be_a_new(Author)
 end
 end
 describe "GET #edit" do
 it "requires login" do
 sign_out login_user
 get :edit, id: author
 expect(response).to require_login
 end
 it "enforces authorization" do
 get :edit, id: author
 expect(controller).to enforce_authorization
 end
 it "assigns the requested author to @author" do
 get :edit, id: author
 expect(assigns(:author)).to eq author
 end
 end
 describe "POST #create" do
 it "requires login" do
 sign_out login_user
 post :create, author: valid_attributes
 expect(response).to require_login
 end
 it "enforces authorization" do
 post :create, author: valid_attributes
 expect(controller).to enforce_authorization
 end
 context "with valid attributes" do
 it "saves the new author in the database" do
 expect{
 post :create, author: valid_attributes
 }.to change(Author, :count).by(1)
 end
 it "redirects to authors#show" do
 post :create, author: valid_attributes
 expect(response).to redirect_to author_path(assigns[:author])
 end
 end
 context "with invalid attributes" do
 it "does not save the new author in the database" do
 expect{
 post :create, author: invalid_attributes
 }.not_to change(Author, :count)
 end
 it "re-renders the :new template" do
 post :create, author: invalid_attributes
 expect(response).to render_template :new
 end
 end
 end
 describe "PATCH #update" do
 it "requires login" do
 sign_out login_user
 patch :update, {id: author, author: valid_attributes}
 expect(response).to require_login
 end
 it "enforces authorization" do
 patch :update, {id: author, author: valid_attributes}
 expect(controller).to enforce_authorization
 end
 context "with valid attributes" do
 it "updates the requested author" do
 patch :update, {id: author, author: valid_attributes}
 author.reload
 expect(author).to have_attributes(valid_attributes)
 end
 it "redirects to the updated author" do
 patch :update, id: author, author: valid_attributes
 expect(response).to redirect_to author_url
 end
 end
 context "with invalid attributes" do
 it "does not update the requested author" do
 # Do not attempt to "refactor" the following to any of the following:
 # not_to change { author }
 # not_to change { author.attributes }
 # not_to have_attributes(invalid_attributes)
 # None of the above will work. See
 # https://github.com/rspec/rspec-expectations/issues/996#issuecomment-310729685
 expect {
 patch :update, id: author, author: invalid_attributes
 }.not_to change { author.reload.attributes }
 end
 it "re-renders the :edit template" do
 patch :update, id: author, author: invalid_attributes
 expect(response).to render_template :edit
 end
 end
 end
 describe "DELETE #destroy" do
 let!(:author) { FactoryGirl.create(:author) }
 it "requires login" do
 sign_out login_user
 delete :destroy, id: author
 expect(response).to require_login
 end
 it "enforces authorization" do
 delete :destroy, id: author
 expect(controller).to enforce_authorization
 end
 it "deletes the author" do
 # Must use let! (bang) to create author. Or else, it is both created
 # and deleted INSIDE the block, causing the count not to change.
 expect{
 delete :destroy, id: author
 }.to change(Author, :count).by(-1)
 end
 it "redirects to authors#index" do
 delete :destroy, id: author
 expect(response).to redirect_to authors_url
 end
 end
end

The app uses Devise for authentication. The following custom matcher asserts that unauthenticated users get redirected to the log-in page.

# spec/support/matchers/require_login.rb
RSpec::Matchers.define :require_login do |expected|
 match do |actual|
 expect(actual).to redirect_to \
 Rails.application.routes.url_helpers.new_user_session_path
 end
 failure_message do |actual|
 "expected to require login to access the method"
 end
 failure_message_when_negated do |actual|
 "expected not to require login to access the method"
 end
 description do
 "redirect to the login form"
 end
end

The app uses Pundit for authorization. The following custom matcher asserts that Pundit's authorize or policy_scope method is called within the controller action.

# spec/support/matchers/enforce_authorization.rb
# This custom matcher asserts that Pundit's #authorize or #policy_scope were
# called by the tested controller action. Given that the custom matcher is
# called *after* #authorize or #policy_scope is called, it is unable to mock
# or spy on those methods. Hence, it requires the following lines of code to be
# included in spec_helper.rb
# config.before(:each, :type => :controller) do |spec|
# allow(controller).to receive('policy_scope').and_call_original
# allow(controller).to receive('authorize').and_call_original
# end
RSpec::Matchers.define :enforce_authorization do |expected|
 match do |actual|
 if actual.request.parameters[:action] == "index"
 expect(actual).to have_received(:policy_scope)
 else
 expect(actual).to have_received(:authorize)
 end
 end
 failure_message do |actual|
 "expected action to enforce authorization policy"
 end
 failure_message_when_negated do |actual|
 "expected action NOT to enforce authorization policy to access the method"
 end
 description do
 "enforce authorization policy"
 end
end

Thanks in advance.

asked Jul 4, 2017 at 13:14
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

You've done a good job. The tests are simple and the code communicates its intent well. The comments are excellent, especially the "do not change this code" comment. You've made good use of customized matchers. There isn't much I'd change in this code.

Put login_admin in a before block

In this code,

RSpec.describe AuthorsController, :focus, :type => :controller do
 login_admin
 ...
end

login_admin is run at the time the spec file is loaded, not at the time the test is run. This is a bit of a fine distinction, because the file is loaded just before the test is run, but it does not communicate its intent well. To prevent confusion, it should be moved into a proper before block:

RSpec.describe AuthorsController, :focus, :type => :controller do
 before do
 login_admin
 end
 ...
end

This will log in before each test. It is usually better do to setup actions like this before each test; doing so helps to keep test cases independent of each other.

If you do want to login_admin just once, before any test is run, then change the before block to:

 before(:all) do
 login_admin
 end

Another way to deal with author needing to be a let! block

Another way to deal with author needing to be created outside of the expect is to reference it:

let(:author) { ... }
...
it "deletes the author" do
 # Force author to exist before the `expect` block.
 # Otherwise, it is both created
 # and deleted INSIDE the block, causing the count not to change.
 author
 expect{
 delete :destroy, id: author
 }.to change(Author, :count).by(-1)
end

This is not objectively better than what you've done, except in one way: It keeps the workaround close to where it is needed.

answered Jul 9, 2017 at 18:09
\$\endgroup\$
1
  • \$\begingroup\$ I have implemented both of your suggestions. Thank you. \$\endgroup\$ Commented Jul 10, 2017 at 16:09

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.