RSpec ⛑

Hero image for RSpec ⛑

To test Rails applications, we prefer using RSpec over MiniTest but restrain ourselves to its core features and use 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
    

Assertion

Single expectation test

  • 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) }
    
  • For integration tests, sometimes the tests may suffer a performance hit especially when setup is complex. In those cases, it’s fine to assert more than one behavior.

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)
    

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
    

When using VCR is not option, we recommend creating a light mock web server using Sinatra.