Rails Testing

General rules

  • Use RSpec for testing.
  • Use WebMock to mock external HTTP requests.
  • Use FactoryGirl to load data into database.
  • Use Timecop to change or freeze date.
  • Use RSpec’s Expect syntax.
  • Use not_to instead of to_not for negative expectations.
  • Don’t use instance variables.
  • Avoid let!. Use let instead and build objects in before block when needed. If you need to load multiple objects in before statement - place them into array:

        before do
          [ object_1, object_2, object_2 ]
        end
    

Tips

  • When running tests in RRRspec, check for error traces in WORKERS section. Usually it gives good idea which workers are crashing and why.

Unit & Functional tests

  • Use render_views to make sure views don’t crash.
  • Use context to nest tests and define data or precondition. When describing a context use “when”, “with”, “on” or similar words:

        context 'when user has name' do
          let(:name) { 'John' }
    
          it 'is valid' do
            expect(user).to be_valid
          end
        end
    
        context 'with no name' do
          let(:name) { nil }
    
          it 'user is valid' do
            expect(user).not_to be_valid
          end
        end
    
  • Use described_class to avoid repeating class name.
  • Test only one thing in each example.
  • Use .method_name to describe class methods.
  • Use #method_name to describe instance methods.
  • When testing interactors use only top level describe and nest tests into context.
  • Use imperative mood to write test descriptions.
  • Keep example descriptions short.
  • Don’t test configuration.
  • Don’t use subject.
  • Don’t use one-liner example syntax.

Feature tests

  • Use Capybara with rspec syntax to write feature tests.
  • Place tests into spec/features directory.
  • Use correct content/selector matchers for performance reasons:
    • When you expect element to be in page, use matchers that wait for elements to appear:

          have_selector
          have_xpath
          have_css
          have_link
          have_button
          have_field
          have_checked_field
          have_unchecked_field
          have_select
          have_table
          have_text
          have_content
      
    • When you expect element not to be in page, use matchers that wait for elements to disappear:

          have_no_selector
          have_no_selector
          have_no_xpath
          have_no_css
          have_no_link
          have_no_button
          have_no_field
          have_no_checked_field
          have_no_unchecked_field
          have_no_select
          have_no_table
          have_no_text
          have_no_content
      
  • Use real browser to run tests (eg, Chrome). This ensures that test cases will be more real-life.
  • Use Headless to run feature tests on CI.
  • Extract repeated test initialization into methods:

        def visit_my_page
          visit '/my_page'
          click 'My button'
          wait_for_ajax
        end
    
        it 'tests something' do
          visit_my_page
          # ....
        end
    
        it 'tests another thing' do
          visit_my_page
          # ....
        end
    
  • Write methods that wait for ajax to complete or react-js component to finish rendering.
  • Tag examples that test JavaScript-heavy application parts and increase Capybara timeouts.
  • Make sure test files do not run too long - each file should finish within 2 minutes. That way tests will parallelize better when executed in rrrspec or parallel_tests.
  • Use truncation strategy from Database Cleaner to clean database after each example.
  • Don’t respect single assert per example rule - preparation for example takes lots of time, so it’s better to have multiple asserts in one example.

Request tests

  • Use request tests to test API endpoints.
  • Test response code and body.
  • Use helper methods for testing json body:

        def json_body
          ActiveSupport::JSON.decode(response.body)
        end
    
        it 'tests my_api' do
          get '/api/v1/my_api'
    
          expect(json_body['my_key']).to eq('my_value')
        end
    

Mutant

We use Mutant to ensure robustness of the test suite. Mutant creates mutations by deleting or modifying pieces of code (e.g. changes < to >) and runs the corresponding tests. If tests succeed, then this mutation was not killed and it will be printed in the output. In this case, we need to improve the test suite.

Tool execution speed:

  • Fast, if executed on isolated and tested pieces of code - interactors, services, policies, etc.
  • Slow, if executed on models which touch a lot of tests (especially feature tests).

Mutant should be used at the end of a feature development. It is also useful when used before refactoring code to check if code to be refactored is covered with tests.

How to run:

RAILS_ENV=test SPEC_OPTS="--pattern spec/interactors/some/interactor_spec.rb" bundle exec mutant -r ./config/environment --jobs 1 --use rspec 'Some::Interactor'
               |                                                                                                                              |
               Run only this specific spec.                                                                                                   Namespace with class name.

The most common mistake when writing the test suite

When writing the test suite, quite often, not all of the code conditions and branches are tested. For example, the data set might not be big enough to cover all of the code branches or the test suite might need additional contexts.

To cover all of the different code branches, either:

  1. Create more than one record with different states and expect the output to match only a subset of the initial data.
  2. Add additional context to the test suite.

In the following examples, only one record was created in the test suite with only one context. As a result:

  • The scope is vulnerable to future changes and the test suite will not detect them.

       def reindex_items
      -  Item.where(owner_type: "User", owner_id: some_scope.ids).reindex!
      +  Item.where(nil).reindex!
       end
    
  • The condition can be deleted and the test suite will not detect it.

       def confirm_user
      -  if current_user.unconfirmed
      -    current_user.confirm
      -  end
      +  current_user.confirm
       end
    
  • The whole branch can be deleted and the test suite will not detect it.

       def after_create
          item_id = params[:item_id]
      -  if item_id
      -    redirect_to(items_path(item_id: item_id))
      -  else
      +  unless item_id
          redirect_to(items_path)
          end
       end
    

False positives

We treat some of the mutations as false positives. This list includes, but is not limited to:

  • Data sorting.

    Mutant shows code which sorts some data as untested, after deleting that code specs are failing (most of the time). For example:

    • Code sorts some data.
    • Specs for that code expect that the data is sorted.
    • Mutant deletes the code which sorts the data.
    • Sometimes specs will succeed because the data is retrieved in a random order from the database.

    In this case:

    • Make sure that the dataset in the spec is as varied as possible (e.g. if user first_name and last_name is sorted, create all possible variations of first_name and last_name).
    • Consider increasing the size of the data set to increase the chance of failure in these specs.
  • Constant resolving.

       def can_access?
      -  user.is_a?(::Admin) && (!user.anonymous?)
      +  user.is_a?(Admin) && (!user.anonymous?)
       end
    

    In this example, when Admin is called without ::, wrong Admin class will be loaded if two of them exists in the project (e.g. Admin and SomeOther::Admin).

  • Transactions (except the ones which are easy to test).

       def attempt_to_create_agreement
      -  Agreement.transaction do
      -    agreement.save!
      -  end
      +  agreement.save!
       end
    
  • params.fetch

       def receiver
      -  @receiver ||= User.by_email(params[:receiver_email])
      +  @receiver ||= User.by_email(params.fetch(:receiver_email))
       end
    
  • eql?

       def call
      -  if user == item.owner
      +  if user.eql?(item.owner)
          item.destroy!
          else
          notify_admin
          end
       end
    

    Use eql? for strict value comparison, for example, when you need to differentiate between Integer and Float.

  • instance_of?

       def call
      -  if item.is_a?(Item)
      +  if item.instance_of?(Item)
           item.destroy!
         end
       end
    
  • includes

       def index
          respond_to do |format|
          format.html
          format.json do
      -      render(json: items.includes(:sender))
      +      render(json: items.includes(nil))
          end
          end
       end
    

    Ignored because not possible to test what was included.

  • record.id

       def excluded_user
      -  company.users.where.not(id: excluded.id).ids
      +  company.users.where.not(id: excluded).ids
       end
    

Optimizing tests

Over the time as the project grows and new features are added your spec suite is bound to expand. As new specs are added it is very likely that somewhere, a few unconsidered statements will be added that will heavily slow down your suite.

The most common cases are:

  • Exponential factory dependency growth (a factory that spawns tons of dependencies creating a large amount of records).
  • Unnecessary record creation.
  • Live requests in feature specs.

If you’re new to the project and have no idea where to look at, here’s a link to this nice article that gives quite a few suggestions on how to profile your spec suite.

In practice, the most common causes are likely to be either disregarded factories that create useless records or large data structures built using the factory - which is rather slow.

If reviewing the factory and optimizing the useless record creation didn’t help there are only a few solutions available:

  • Optimizing the SQL (e.g. activerecord-import).
  • Preloading the data into the general data set. (if your project has one).
  • Using context-aware record caching. (e.g. once-ler).

Once-ler

RSpec.configure do |config|
  config.include Onceler::BasicHelpers
end

This gem uses Rails virtual nested transactions (save points) which are currently only supported only by MySQL and PostgreSQL. The idea behind the speed-up is running a factory that creates a record -once-. The gem sets a save point after running the code that is supposed to be run only once, then loads and uses Marshal.load to restore the returned variable when it has been already created, without re-running the SQL statements.

Use this approach wherever you have time-consuming let definitions. It will speed up your specs tremendously.