RSpec ⛑
To test Rails applications, the team prefers using RSpec over MiniTest but restrains itself to its core features and uses the following best practices.
All general testing best practices and Web testing best practices apply to RSpec but working with a framework adds another superset of conventions to follow.
Formatting
-
Use a single, top-level
describe ClassName
block, with appropriatetype
.RSpec.describe SomeClass, type: model do; end RSpec.describe SomeClass, type: system do; end
-
Order validation, association, and method tests in the same order that they appear in the class.
-
describe
to group tests by method-under-test.- Use . (or ::) when referring to a class method’s name.
- Use # when referring to an instance method’s name.
describe 'the confirm method of User' describe 'search method of User'
describe '#confirm' describe '.search'
-
Use
context
to describe testing preconditions.context
block descriptions should always start with ‘when’ or ‘given’, and be in the form of a sentence with proper grammar.it 'processes payment if the params are valid' it 'declines payment if the params are invalid'
context 'when params are valid' do it 'processes payment if the params are valid' end context 'when params are invalid' do it 'declines payment' end
-
Use
subject
blocks to define objects for use in one-line specs only.context 'when using an explicit subject' do subject { 'foo' } it 'should equal foo' do # it's not okay to use `subject` here: expect(subject).to eq('foo') end end
context 'when defining a subject' do # it's okay to define a `subject` here: subject { 'foo' } it { expect(subject).to eq('foo') } end
-
Put one-liner specs at the beginning of the outer describe blocks.
RSpec.describe 'validations' do it 'validates uniqueness of name' do Fabricate(:place, name: 'Bangkok') place_with_same_name = Fabricate.build(:place, name: 'Bangkok') expect(place_with_same_name).not_to be_valid end it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:slug) } end
RSpec.describe 'validations' do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:slug) } it 'validates uniqueness of name' do Fabricate(:place, name: 'Bangkok') place_with_same_name = Fabricate.build(:place, name: 'Bangkok') expect(place_with_same_name).not_to be_valid end end
-
Always use the
expect
syntax.it 'creates a resource' do response.should respond_with_content_type(:json) end
it 'creates a resource' do expect(response).to respond_with_content_type(:json) end
-
Don’t prefix
it
block descriptions withshould
. Use the imperative tone instead.it 'should process payment'
it 'processes payment'
should
is an artifact from a older Rspec version (< 3) -
Do not leave line breaks after
context
ordescribe
blocks.RSpec.describe Place, type: :model do describe '#create' do it 'creates a place' do # ... end end end
RSpec.describe Place, type: :model do describe '#create' do it 'creates a place' do # ... end end end
-
Leave one line return around it blocks.
describe '#summary' do it 'returns the summary' do # ... end it 'does something else' do # ... end it 'does another thing' do # ... end end
describe '#summary' do it 'returns the summary' do # ... end it 'does something else' do # ... end it 'does another thing' do # ... end end
Fixtures
Factories
-
Use a gem like
fabrication
orfactory_bot
to reduce the verbosity when working with models.user = User.create(name: 'Genoveffa', surname: 'Piccolina', city: 'Billyville', birth: '17 August 1982', active: true)
user = Factory.create(:user)
Fixtures isolation
-
DO NOT use
let
,let!
to create fixtures and limit usage ofbefore
andafter
. -
Instead, each test must create its own fixtures and setup.
RSpec.describe Article do # arrange let(:user) { FactoryGirl.create(:user) } before do # arrange end after do # cleanup end describe '#summary' do # act # assertion end end
RSpec.describe Article do describe '#summary' do # arrange user = FactoryGirl.create(:user) # act # assertion end end
Keep creation of fixtures to a minimum
-
Test suites can become very slow due to haphazard fixtures creation.
-
Try to make the tests atomic and as such avoid unnecessary fixtures creations.
it 'updates the cache counter' do place = Fabricate(:place) # Unnecessary creation of 5 records Fabricate.times(5, :checkin, place: place) expect(place.checkins_count).to eq 5 end
it 'updates the cache counter' do place = Fabricate(:place) Fabricate(:place, :checkin, place: place) expect(place.checkins_count).to eq 1 end
-
Avoid unnecessary database interactions.
it 'returns the user\'s full name' do # Unnecessary information in database user = Fabricate(:user, first_name: 'John', last_name: 'Terry') expect(user.full_name).to eq 'John Terry' end
it 'returns the user\'s full name' do # Just build the user object user = Fabricate.build(:user, first_name: 'John', last_name: 'Terry') expect(user.full_name).to eq 'John Terry' end
Assertion
Expectation(s) per test
-
For unit tests — i.e. running in isolation, each test should make only one assertion.
it 'creates a resource' do expect(response).to respond_with_content_type(:json) expect(response).to assign_to(:resource) end
it { is_expected.to respond_with_content_type(:json) } it { is_expected.to assign_to(:resource) }
-
Integration tests — i.e., mainly request, service, and some model tests may suffer a performance hit when the setup is complex. Therefore, each integration test can contain multiple assertions.
RSpec.describe SendInvitationService, type: :service do describe '#call' do context 'given valid params' do it 'returns true' do company = Fabricate(:company) user = Fabricate(:user, company: company) invitation = Fabricate(:invitation, status: :draft) service = described_class.new(invitation: invitation, user: user) result = service.call expect(result).to be(true) end it 'sends the invitations' do company = Fabricate(:company) user = Fabricate(:user, company: company) invitation = Fabricate(:invitation, status: :draft) service = described_class.new(invitation: invitation, user: user) result = service.call expect(invitation.status).to eq(:sent) end end end end
RSpec.describe SendInvitationService, type: :service do describe '#call' do context 'given valid params' do it 'returns true and sends the invitations' do company = Fabricate(:company) user = Fabricate(:user, company: company) invitation = Fabricate(:invitation, status: :draft) service = described_class.new(invitation: invitation, user: user) result = service.call expect(result).to be(true) expect(invitation.status).to eq(:sent) end end end end
-
For system tests — i.e. UI tests, tests with multiple expectations are the norm: once a scenario has run, a system test often needs to assert the presence of several UI elements and their values.
describe 'Delete an event', type: :system do context 'given the delete button is clicked' do it 'displays the confirmation message within the delete modal' do [...] end it 'displays the cancel button within the delete modal' do [...] end it 'displays the delete button within the delete modal' do [...] end end end
describe 'Delete an event', type: :system do context 'given the delete button is clicked' do it 'displays the delete modal' do user = Fabricate(:user) event = Fabricate(:event) login_as user visit edit_event_path(id: event) click_link I18n.t('helpers.submit.delete', resource: Event.model_name) within '.modal[data-testid="modal_delete"]' do expect(page).to have_content(I18n.t('activerecord.messages.destroy.confirm.default', resource: event.name)) expect(page).to have_button(I18n.t('activerecord.actions.cancel')) expect(page).to have_button(I18n.t('helpers.submit.delete')) end end end end
Test all possible cases
-
Use Boundary value analysis technique to test valid, edge and invalid cases.
-
Split-up method’s input or object’s attributes into valid and invalid partitions and test both of them and their boundaries.
RSpec.describe '#month_in_english' do context 'when valid' do it 'returns "January" for 1' # lower boundary end context 'when invalid' do it 'returns nil for 0' end end
RSpec.describe '#month_in_english' do context 'when valid' do it 'returns "January" for 1' # lower boundary it 'returns "March" for 3' it 'returns "December" for 12' # upper boundary end context 'when invalid' do it 'returns nil for 0' it 'returns nil for 13' end end
Incidental State
-
Avoid incidental state as much as possible.
it 'publishes the article' do article.publish # Creating another shared Article test object above would cause this # test to break expect(Article.count).to eq(2) end
it 'publishes the article' do expect { article.publish }.to change(Article, :count).by(1) end
Readable matchers
-
Use readable matchers and double check the available rspec matchers.
lambda { model.save! }.to raise_error Mongoid::Errors::DocumentNotFound
expect { model.save! }.to raise_error Mongoid::Errors::DocumentNotFound
- Avoid checking boolean equality directly.
# Class under test: class Thing def awesome? true end end
it 'is true' do thing = Thing.new expect(thing.awesome?).to eq(true) end
it 'is true' do thing = Thing.new expect(thing).to be_awesome end
-
Use named HTTP status like
:no_content
over its numeric representation. See a list of supported HTTP status codesexpect(response).to have_http_status(204)
expect(response).to have_http_status(:no_content)
Testing Scope
In Ruby on Rails, developers have many types of tests, each suits different needs. It can be overwhelming and lead to inefficient test suites – e.g. too many tests, tests of different types that cover the same behavior or business logic, wrong type of tests leading to slower tests, etc.
The below testing scope is a starting point to guide when to use what type of test. It helps teams understand the purpose of each type and reduces the need for future refactoring of the test.
Model Tests
Model tests focus mainly on business logic assertion, covering public methods and validations (for ActiveModels).
RSpec.describe Event do
describe 'Validations' do
it { is_expected.to validate_presence_of(:started_at) }
it { is_expected.to validate_presence_of(:ended_at) }
end
describe '#duration_in_hours' do
it 'returns the duration in hours between the started and ended times' do
started_at = Time.zone.parse('01/01/2020 10:00')
ended_at = Time.zone.parse('01/01/2020 12:30')
event = Fabricate.build(:event, started_at: started_at, ended_at: ended_at)
expect(event.duration_in_hours).to eq(2.5)
end
end
end
Avoid tests that assert the technical implementation instead of the business logic, for example:
RSpec.describe Event, type: :model do
describe 'Associations' do
it { is_expected.to belong_to(:company) }
it { is_expected.to have_many(:activities) }
end
describe 'DB columns' do
it { is_expected.to have_db_column(:company_id) }
it { is_expected.to have_db_column(:started_at) }
it { is_expected.to have_db_column(:ended_at) }
end
describe 'Delegations' do
it { is_expected.to delegate_method(:formated_started_at).to(:event_decorator)
it { is_expected.to delegate_method(:formated_ended_at).to(:event_decorator)
end
end
System Tests
In line with the Testing Pyramid, which states that UI tests are slower to execute by a significant order of magnitude, System Tests should focus on the happy paths instead of covering all possible test cases. A single test can cover a complete flow with multiple assertions.
-
describe 'Edit an existing event', type: :system do context 'given the form is filled in with valid params' do it 'updates the event and redirects to the edit event page' do user = Fabricate(:user) event = Fabricate(:event, name: 'Old name') login_as user visit edit_event_path(id: event) within('form[data-testid="event_form"]') do expect(page).to have_field('team[name]', with: 'Old name') fill_in 'event[name]', with: 'A new name!', fill_options: { clear: :backspace } click_button I18n.t('actions.update') end expect(page).to have_field('team[name]', with: 'A new name!') expect(page).to have_current_path(edit_team_path(id: team)) end end end
describe 'Edit an existing event', type: :system do it 'updates the event and redirects to the edit event page' do user = Fabricate(:user) event = Fabricate(:event, name: 'Old name') # ... # This assertion is not from the user perspective! expect(event.reload.name).to eq('A new name!') end end
-
describe 'Edit an existing event', type: :system do it 'has the action buttons' do # ... end it 'has the form title' do # ... end it 'has the field name' do # ... end # ... end
Request Tests
Request tests run faster, more reliably than system tests, and are recommended to make the following assertions:
-
-
-
-
expect(post_request).to render_template :new
) -
expect(response).to redirect_to root_path
describe 'Events', type: :request do
describe 'POST #create' do
context 'given valid params' do
it 'renders the found status' do
# ...
expect(response).to have_http_status(:found)
end
it 'saves the new record' do
# ...
expect do
post events_path(new_event_params)
end.to change(Event, :count).by(1)
created_event = Event.order(:created_at).last
expect(created_event.name).to eq('The name of the new event')
expect(created_event.started_at).to eq(event_started_at)
expect(created_event.ended_at).to eq(event_ended_at)
end
it 'redirects to the events list page with a success message' do
# ...
login_as user
post events_path(new_event_params)
expect(response).to redirect_to(events_path)
follow_redirect!
success_message = css_select('[data-testid="alert_message"]')
expect(success_message.text).to eq(I18n.t('activerecord.messages.create.success', resource: Event.model_name.human.downcase))
end
end
end
end
Avoid tests that assert the inner workings, or the standard behavior of Rails.
describe 'Events', type: :request do
describe 'GET #edit' do
it 'renders the edit template' do
# ...
expect(response).to_not render_template(:edit)
end
it 'renders the edit template' do
# ...
expect(response).to_not render_template(:edit)
end
end
end
Unused types of test
In most projects, the team does not use the following types of tests:
-
-
-
View tests
DRY-ing up tests
-
Avoid using Shared Examples as much as possible. These types of tests do have their place in a few instances but should not be the goto method to write tests.
-
Extract reusable code into helper methods.
# spec/systems/user_signs_in_spec.rb RSpec.describe 'User can sign in', type: :system do scenario 'logs a user into the application' do user = Fabricate(:user) visit root_path fill_in 'user_session_email', with: user.email fill_in 'user_session_password', with: user.password click_button 'Sign in' expect(page).to have_content 'Your account' end end
# spec/systems/user_signs_in_spec.rb RSpec.describe 'User can sign in', type: :system do it 'logs a user into the application' do sign_in expect(page).to have_content 'Your account' end end # spec/support/authentication_helper.rb module AuthenticationHelper def sign_in user = Fabricate(:user) visit root_path fill_in 'user_session_email', with: user.email fill_in 'user_session_password', with: user.password click_button 'Sign in' end end
Test Doubles
-
Use stubs, mocks and spies to isolate the test to the object under test.
-
When resorting to mocking and stubbing, only mock against a small, stable, and obvious (or documented) API, so stubs are likely to represent reality after future refactoring.
-
Avoid stubbing a method to a level where it could give a false-positive test result.
Stubbing HTTP requests
-
Stub all network requests. Whenever possible, prefer using VCR.
it 'creates a new answer' do user = Fabricate(:user) # makes a network request service = described_class.new(service_params(user.id)) expect(service.call).to be_a(Api::V1::ForumAnswer) end
it 'creates a new answer', vcr: 'api/forums/valid-answer' do user = Fabricate(:user) # network request is stubbed by VCR service = described_class.new(service_params(user.id)) expect(service.call).to be_a(Api::V1::ForumAnswer) end
Creating a light mock web server using Sinatra is recommended when using VCR is not an option.