← All Articles

Recommended Rspec style for Rails models

Rspec is the most common testing framework for Ruby on Rails, and when written well the unit tests written in Rspec can be fast to execute and effective in ensuring high code quality - and avoided those dreaded regression bugs. 

Most of these "rules" below are just my top recommendations. See http://betterspecs.org/ for a full list of common defaults or zoom to the end of this article if you want to see a sample test which includes all the rules I recommend.

Contents:

 

Rule 1 : Structure test folders the same as application code

Mirroring the directory structure just makes it easier to find and understand which test applies to which class.

For example if your code folders look like this:


- app
  - ...
  - models
    - concerns
      - like.rb
    - tracking
      - referrer.rb
    - activity.rb


Then your rspec folders should look like this:


- spec
  - ...
  - models
    - concerns
      - like_spec.rb
    - tracking
      - referrer_spec.rb
    - activity_spec.rb

 

Rule 2 : Describe class and instance methods concisely

Let's say you have an Activity model with a default class method and a next instance method. Your test should include describe blocks with a .default and a #next for the class and instance methods respectively.

That is:


require 'spec_helper'

describe Activity do

  describe '.default' do
    # test goes here
  end

  describe '#next' do 
    # test goes here
  end
end

 Do not use long descriptions such as:


require 'spec_helper'

describe Activity do

  describe 'default activities to display on the homepage' do
    # test goes here
  end

end

 

Rule 3 : Test private and protected methods also

Don't ignore private and protected methods, as they can often perform vital work in your model. Test these by using the send method on your class / instance. ie


describe '#update_charge' do
    subject { activity.send(:update_charge, new_charge) }
    let(:new_charge) { 21 }

    it "changes the activity's charge" do
        subject
        expect(activity.reload.charge).to eq(new_charge)
    end

end

 

Rule 4 : "Context" blocks outline different states and starts with 'when'

Use contexts to describe different scenarios, instead of cluttering your test descriptions. A good way to use contexts correctly is always start with 'when' as this forces you to describe a scenario. For example:


describe '#update_charge' do
    subject { activity.send(:update_charge, -21) }

    context 'when the charge exceeds 100' do
        # test here
    end

    context 'when the charge does not exceed 100' do
        # test here
    end

end

 

Rule 5 : One assertion per test

Do not include multiple assertions in one unit test, if your tests can be clearly expressed in one assertion per test - this helps to make your test cases more readable.

However in some cases where setting up for each test may take some time (ie a lot of data is loaded), it is acceptable to skip this rule (but try not to!)

Do this:


it 'returns 4 records' do
    expect(subject.count).to eq(4)
end

it 'returns only user records' do
    expect(subject.map(&:class).uniq) to eq([User])
end

Instead of this:



it 'returns 4 user records' do
    expect(subject.count).to eq(4)
    expect(subject.map(&:class).uniq) to eq([User])
end

 

Rule 6 : Test outputs, not inner workings

Remember these are unit tests, which means you want to be confident of what the method returns. Do not worry about the implementation details.

Do this:



it "returns the item's price" do
    expect(subject).to eq(21.99)
end

Not this:



it "finds the item's wholesale price" do
    expect(Item).to receive(:wholesale_price).and_call_original
    subject
end

it "multiplies the wholesale price by markup percentage" do
    subject
    expect(item.markup_price).to eq(item.wholesale_price * item.markup)
end

 

Rule 7 : Start with the subject

Starting with the subject at the start of the describe or context block makes it clear what you are testing. Do this:


describe '.next_three_bills' do
    subject { User.next_three_bills }

    # test 1

    # test 2

    # other tests....

end

 

Rule 8 : Create only the data which is being tested

Avoid the temptation of creating lots of different objects / records if they are not being used in the test itself. This will make your tests easier to understand and keep up to date, as there is less confusion of what is required in the test.

 

Rule 9 : Mock objects and methods which are not being tested

Related to Rule 8 above, if an object or method is not being tested in a particular unit test, then mock it out. It should be tested in its own unit test somewhere else. eg:


describe '.next_three_bills' do
    subject { User.next_three_bills }

    before { allow(Bill).to receive(:is_valid?).and_return(true) }

    # tests....

end

 

Rule 10 : Place before / after blocks at the start of the test

See the example in Rule 9 above. By placing these before / after blocks at the start of the test, it makes it clear what your assumptions are when setting up the test.

 


 

Here's a sample test for a simple model which incorporates the rules discussed here.


require 'spec_helper'

RSpec.describe BillSummary do

  let(:datetime) { Time.zone.parse('2014-09-22T15:00') }
  let(:tenant) { FactoryGirl.create(:tenant) }
  let!(:device) { FactoryGirl.create(:device, tenant: tenant) }

  before do
    Time.zone = 'Pacific Time (US & Canada)'
    Timecop.travel(datetime)
  end

  describe '.create_for_new_period' do
    subject { described_class.create_for_new_period(device) }

    it 'calls calculate_items' do
      expect_any_instance_of(described_class).to receive(:calculate_items).once
      subject
    end

    it 'saves a new summary' do
      expect{subject}.to change{described_class.count}.from(0).to(1)
    end
  end

  describe '#change_user' do
    subject { device.send(:change_user, user)}

    let!(:user) { create(:user) }

    context 'when the user is an admin user' do
        before { allow(user).to receive(:admin?).and_return(true) }
        
        it "changes the device owner" do
            subject
            expect(device.reload.owner).to eq(user)
        end
    end

    context 'when the user is *not* an admin user' do
        it "does not change the device owner" do
            subject
            expect(device.reload.owner).to_not eq(user)
        end
    end
  end

 

Made with JoyBird