Skip to content

Instantly share code, notes, and snippets.

@fotinakis
Last active December 8, 2023 14:18
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fotinakis/3a532a0929f64b4b5352 to your computer and use it in GitHub Desktop.
Save fotinakis/3a532a0929f64b4b5352 to your computer and use it in GitHub Desktop.
Custom rspec matcher for testing CanCan abilities
# 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
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
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
Copy link

dankohn commented Oct 20, 2014

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:

# https://github.com/CanCanCommunity/cancancan/wiki/Testing-Abilities
# https://gist.github.com/fotinakis/3a532a0929f64b4b5352
# 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 do
    obj_name = @obj.class.name
    obj_name = @obj.to_s.capitalize if [Class, Module, Symbol]
      .include?(@obj.class)
    "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)
      fail ArgumentError, '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 do
    obj_name = @obj.class.name
    obj_name = @obj.to_s.capitalize if [Class, Module, Symbol]
      .include?(@obj.class)
    "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)
    return if ability.class.ancestors.include?(CanCan::Ability)
    fail TypeError, 'subject must mixin CanCan::Ability, got a ' \
      "#{ability.class.name} class."
  end
end

@Hettomei
Copy link

@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"

Copy link

ghost commented Jan 22, 2015

Thanks a lot! 😃 )
Also, small deprecation warning ;)

`failure_message_for_should` is deprecated. Use `failure_message` instead.

@andywenk
Copy link

andywenk commented Feb 3, 2015

awesome! thanks!!!

@benbabics
Copy link

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)?

@j0an
Copy link

j0an commented May 25, 2017

@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