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
Types
-
Use
Request tests
overController tests
.
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)
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.