Last active
August 25, 2024 16:44
-
-
Save fotinakis/3a532a0929f64b4b5352 to your computer and use it in GitHub Desktop.
Custom rspec matcher for testing CanCan abilities
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Custom rspec matcher for testing CanCan abilities. | |
# Originally inspired by https://github.com/ryanb/cancan/wiki/Testing-Abilities | |
# | |
# Usage: | |
# should have_abilities(:create, Post.new) | |
# should have_abilities([:read, :update], post) | |
# should have_abilities({manage: false, destroy: true}, post) | |
# should have_abilities({create: false}, Post.new) | |
# should not_have_abilities(:update, post) | |
# should not_have_abilities([:update, :destroy], post) | |
# | |
# WARNING: never use "should_not have_abilities" or you may get false positives due to | |
# whitelisting/blacklisting issues. Use "should not_have_abilities" instead. | |
RSpec::Matchers.define :have_abilities do |actions, obj| | |
include HaveAbilitiesMixin | |
match do |ability| | |
verify_ability_type(ability) | |
@expected_hash = build_expected_hash(actions, default_expectation: true) | |
@obj = obj | |
@actual_hash = {} | |
@expected_hash.each do |action, _| | |
@actual_hash[action] = ability.can?(action, obj) | |
end | |
@actual_hash == @expected_hash | |
end | |
description do | |
obj_name = @obj.class.name | |
obj_name = @obj.to_s.capitalize if [Class, Module, Symbol].include?(@obj.class) | |
"have abilities #{@expected_hash.keys.join(', ')} on #{obj_name}" | |
end | |
failure_message_for_should do |ability| | |
obj_name = @obj.class.name | |
obj_name = @obj.to_s.capitalize if [Class, Module, Symbol].include?(@obj.class) | |
message = ( | |
"expected user to have abilities: #{@expected_hash} for " + | |
"#{obj_name}, but got #{@actual_hash}" | |
) | |
end | |
end | |
RSpec::Matchers.define :not_have_abilities do |actions, obj| | |
include HaveAbilitiesMixin | |
match do |ability| | |
verify_ability_type(ability) | |
if actions.is_a?(Hash) | |
raise ArgumentError.new( | |
'You cannot pass a hash to not_have_abilities. Use have_abilities instead.') | |
end | |
@expected_hash = build_expected_hash(actions, default_expectation: false) | |
@obj = obj | |
@actual_hash = {} | |
@expected_hash.each do |action, _| | |
@actual_hash[action] = ability.can?(action, obj) | |
end | |
@actual_hash == @expected_hash | |
end | |
description do | |
obj_name = @obj.class.name | |
obj_name = @obj.to_s.capitalize if [Class, Module, Symbol].include?(@obj.class) | |
"not have abilities #{@expected_hash.keys.join(', ')} on #{obj_name}" if @expected_hash.present? | |
end | |
failure_message_for_should do |ability| | |
obj_name = @obj.class.name | |
obj_name = @obj.to_s.capitalize if [Class, Module, Symbol].include?(@obj.class) | |
message = ( | |
"expected user NOT to have abilities #{@expected_hash.keys.join(', ')} for " + | |
"#{obj_name}, but got #{@actual_hash}" | |
) | |
end | |
end | |
module HaveAbilitiesMixin | |
def build_expected_hash(actions, default_expectation:) | |
return actions if actions.is_a?(Hash) | |
expected_hash = {} | |
if actions.is_a?(Array) | |
# If given an array like [:create, read] build a hash like: | |
# {create: default_expectation, read: default_expectation} | |
actions.each { |action| expected_hash[action] = default_expectation } | |
elsif actions.is_a?(Symbol) | |
# Build a hash if it's just a symbol. | |
expected_hash = {actions => default_expectation} | |
end | |
expected_hash | |
end | |
def verify_ability_type(ability) | |
if !ability.class.ancestors.include?(CanCan::Ability) | |
raise TypeError.new("subject must mixin CanCan::Ability, got a #{ability.class.name} class.") | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class TestingAbility | |
include CanCan::Ability | |
def initialize(user) | |
can :read, User | |
can :comment, User | |
cannot :destroy, User | |
end | |
end | |
describe 'CanCan custom RSpec::Matchers' do | |
subject(:ability) { TestingAbility.new(user) } | |
let(:user) { create(:user) } | |
let(:other_user) { create(:user) } | |
it { should have_abilities(:read, other_user) } | |
it { should have_abilities(:comment, other_user) } | |
it { should have_abilities({destroy: false}, other_user) } | |
it { should have_abilities([:read], other_user) } | |
it { should have_abilities([:read, :comment], other_user) } | |
it { should have_abilities({read: true}, other_user) } | |
it { should have_abilities({read: true, comment: true}, other_user) } | |
it { should have_abilities({read: true, destroy: false}, other_user) } | |
it { should have_abilities({read: true, comment: true, destroy: false}, other_user) } | |
it { should not_have_abilities(:destroy, other_user) } | |
it { should not_have_abilities([:destroy], other_user) } | |
# These should all expect failure intentionally, to catch false positives. | |
let(:expected_error) { RSpec::Expectations::ExpectationNotMetError } | |
it { expect { should have_abilities(:destroy, other_user) }.to raise_error(expected_error) } | |
it { expect { should have_abilities([:destroy], other_user) }.to raise_error(expected_error) } | |
it { expect { should have_abilities([:read, :destroy], other_user) }.to raise_error(expected_error) } | |
it { expect { should have_abilities({read: true, destroy: true}, other_user) }.to raise_error(expected_error) } | |
it { expect { should have_abilities({read: false, destroy: false}, other_user) }.to raise_error(expected_error) } | |
it { expect { should have_abilities({read: false, destroy: true}, other_user) }.to raise_error(expected_error) } | |
it { expect { should not_have_abilities([:read, :destroy], other_user) }.to raise_error(expected_error) } | |
it { expect { should not_have_abilities({destroy: false}, other_user) }.to raise_error(ArgumentError) } | |
# Never use should_not with have_abilities. | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CanCan custom RSpec::Matchers | |
should have abilities read on User | |
should not have abilities destroy on User | |
should have abilities read, comment on User | |
should have abilities read, comment, destroy on User | |
should have abilities read on User | |
should have abilities destroy on User | |
should have abilities comment on User | |
should have abilities destroy on User | |
should have abilities read, destroy on User | |
should not have abilities read, destroy on User | |
should have abilities read, destroy on User | |
should have abilities read, destroy on User | |
should have abilities destroy on User | |
should not have abilities destroy on User | |
should have abilities read on User | |
should have abilities read, comment on User | |
should have abilities read, destroy on User | |
should have abilities read, destroy on User |
@dankohn Thank you a lot, it works perfectly.
For other people who came from another gist about cancan's custom matcher,
note there is no need to write "for: arguments"
Thanks a lot! 😃 )
Also, small deprecation warning ;)
`failure_message_for_should` is deprecated. Use `failure_message` instead.
awesome! thanks!!!
Sorry for being the newbie here asking a dumb question, but where would one use this to test abilities? would it be within the spec/models/user.rb
, or is there a separate abilities directory one should add for specs per resource (e.g. abilities/posts.rb
)?
@benbabics I use another folder /spec/abilities/something_spec.rb
just to reduce the size of my model specs
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This was excellent, thanks. It should really replace the built in matchers in cancancan, since
be_able_to
produces ugly output. FYI, here is your code reformatted to be Rubocop-clean: