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:
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
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
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
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
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
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
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
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.
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
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