Dec 12 2011

If you haven’t used it, CanCan is a great library for Rails that handles authorization for you. Its calling card is simplicity; just do this:

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new

    if user.is? :paying_customer
      can :show, Article
    else
      can :show, Article, :free => true
      cannot :show, Article, :free => false
    end
  end
end

And then in a controller:

def show
  @article = Article.find(params[:id])
  authorize! :read, @article
end

Super simple! However, as simple as CanCan is, if you want to keep your test times super low, there’s a few things that you should know about.

Incidental coupling: the silent killer

The biggest problem with CanCan from the Fast Tests perspective is that we’ve got coupling with class names. In order to test our Ability class, we also have to load User and Article. Let’s try it without concern for how isolated our tests are:

require "cancan/matchers"
require "spec_helper"

describe Ability do
  let(:subject) { Ability.new(user) }
  let(:free_article) { Article.new(:free => true) }
  let(:paid_article) { Article.new(:free => false) }

  context "random people" do
    let(:user) { nil }

    it "can see free Articles" do
      subject.can?(:show, free_article).should be_true
    end

    it "cannot see non-free Articles" do
      subject.can?(:show, paid_article).should be_false
    end
  end

  context "paying users" do
    let(:user) do 
      User.new.tap do |u|
        u.roles << :paying_customer
      end
    end

    it "can see free Articles" do
      subject.can?(:show, free_article).should be_true
    end

    it "can see non-free Articles" do
      subject.can?(:show, paid_article).should be_true
    end
  end
end

A few notes about testing with CanCan: I like to organize them with contexts for the different kinds of users, and then what their abilities are below. It gives you a really nice way of setting up those let blocks, and our tests read real nicely.

Anyway, so yeah. This loads up all of our models, connects to the DB, etc. This is unfortunate. It’d be nice if we didn’t have to load up User and Article. Unfortunately, there’s no real way around it in our Ability, I mean, if you want them to read all Articles, you have to pass in Article… hmm. What about this?

let(:free_article) { double(:class => Article, :free => true) }
let(:paid_article) { double(:class => Article, :free => false) }

Turns out this works just as well. CanCan reflects on the class that you pass in, so if we just give our double the right class, it’s all gravy. Now we’re not loading Article, but what about User?

The key is in the ||=. If we pass in a User, we won’t call User.new. So let’s stub that:

let(:user) { double(:is? => false) }
let(:user) do 
  double.tap do |u| #tap not strictly needed in this example, but if you want multiple roles...
    u.stub(:is?).with(:paying_customer).and_return(true)
  end
end

Sweet! The first one is a random user: all their is? calls should be false. The second is our user who’s paid up; we need to stub out their role properly, but that’s about it.

It’s that easy

With some dillegence, isolating your tests isn’t hard, and works really well. I have a few more tests in the example I took this from, and 14 examples still take about a third of a second. Not too shabby!

Happy authorizing!

Oh, and the final spec, for reference:

require "cancan/matchers"
require "app/models/ability"

describe Ability do
  let(:subject) { Ability.new(user) }
  let(:free_article) { double(:class => Article, :free => true ) }
  let(:paid_article) { double(:class => Article, :free => false) }

  context "random people" do
    let(:user) { double(:is? => false) }

    it "can see free Articles" do
      subject.can?(:show, free_article).should be_true
    end

    it "cannot see non-free Articles" do
      subject.can?(:show, paid_article).should be_false
    end
  end

  context "users" do
    let(:user) do 
      double.tap do |u|
        u.stub(:is?).with(:user).and_return(true)
      end
    end

    it "can see free Articles" do
      subject.can?(:show, free_article).should be_true
    end

    it "can see non-free Articles" do
      subject.can?(:show, paid_article).should be_true
    end
  end
end