RSpec ⛑

Hero image for 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 appropriate type.

    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.

  • Use describe to group tests by method-under-test.
    1. Use . (or ::) when referring to a class method’s name.
    2. 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 with should. 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 or describe 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 or factory_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 of before and after.

  • 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 codes

    expect(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.

To write tests, the testing scope must be paired with a comprehensive testing strategy. This topic is covered in the Testing Strategy section.

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:

  • Associations
  • DB columns
  • Implicit orders of the default scope (ActiveRecord)
  • Delegations
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.

  • The assertions are made from the perspective of the user.
    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
    

    Changes made in DB must be tested in request tests instead.

  • Avoid testing the presence of every UI element — unless it is an essential part of the flow (e.g. success notice after creating an item or listing items when there is no other flow).
    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:

  • HTTP request status code
  • DB changes (count)
  • Model changes (attribute updates, record created/destroyed, …)
  • HTML assertion that does not require dynamic code (JS/CSS), e.g., flash messages, template rendered (if the action name is different from the template name expect(post_request).to render_template :new)
  • Path redirections 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:

  • Controller tests
  • Route tests
  • View tests

    Controller tests are being deprecated. The Rails community is migrating to use Request tests instead.

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.