Smart Integration Testing in Rails

When we start to talk about testing we always seem to end up discussing integration testing. But is integration testing really our favourite superhero? Or can it become our worst enemy? In this post I'm going to try, using a practical example, to work out how far we can take integration testing and when it would be better to delegate testing to other parts of the application.
Estimate reading 7 minutes

Smart integration tests with RSpec

Recently, I’ve been working on a project for a business client. It’s all very standard. An administrative backend combined with a public frontend.

However, one model in particular caught my attention: the representation of a piece of real estate.

The related database table contains around 60 attributes. Which is a lot, I know. In this case, we can break the model down into numerous sub-models:

class RealEstate < ActiveRecord::Base
  has_one :size
  has_one :owner_registry
  ...
end

Breaking down the model in this way can make it easier to work with, though it can also lead to some problems with forms… but we’ll talk about that later.

And whether we break the model down or not, we’re still going to be left with 60 fields.

Integration testing

This project is relatively simple, so I’ve decided to use it as a test case for identifying ways of improving and strengthening integration testing.

What I want to experiment with is an approach to testing based on the (almost) exclusive use of integration testing, one which makes it possible to check a good proportion of the business code using just a few lines of test code.

The first thing we need to do is think about the scenarios that we have to test for. One approach would be the following (in which the “Pages” module makes use of the SitePrism gem):

require 'rails_helper'

RSpec.feature `Managing real estates`, type: :feature do
  describe `Creating an entry` do
    given(:new_page) { Pages::RealEstate::New.new }

    describe `field xxx` do
      scenario `with a valid value` do
        new_page.load
        new_page.xxx_field.set `valid`

        new_page.submit!

        expect(new_page).to have_notice
      end

      scenario `with an invalid value` do
        new_page.load
        new_page.xxx_field.set `invalid`

        new_page.submit!

        expect(new_page).to have_alert
      end
    end
  end
end

Now, if we want to use an entirely Behaviour-driven Development (BDD) approach, every line of code should be anticipated by a test which justifies its existence.

If we choose to follow this strategy, while using only integration testing, we’re going to have to replicate this code for every field (or at least for every field that’s in need of some kind of validation).

And if you regret it, then?

That’s something you may soon come to regret! When the test code expands in this way it can become a hassle to maintain, which leads to the risk of test writing being abandoned entirely.

Can we do better?

Let’s think for a second about the controller. Within our code, the controller is the point that represents actions a user can carry out in the application:

class RealEstatesController < ApplicationController
  def create
    @resource = RealEstate.create(permitted_params)
    respond_to @resource
  end
end

If we pause to think about the controller, we’ll realise that only two things can happen: either it will create successfully or it will fail.

We should add that if we make use of gems such as SimpleForm and Responders, we should be able to take it for granted that any Flash content will be correctly set up.

So, there will only be two possible scenarios:

require 'rails_helper'

RSpec.feature `Managing real estates`, type: :feature do
  describe `Creating an entry` do
    given(:new_page) { Pages::RealEstates::New.new }

    scenario `with valid values` do
      new_page.load
      new_page.field1_field.set `valid`
      new_page.field2_field.set `valid`
      ...
      new_page.fieldn_field.set `valid`
    new_page.submit!

      expect(new_page).to have_notice
    end

    scenario `with invalid values` do
      new_page.load
      new_page.submit!

      expect(new_page).to have_alert
    end
  end
end

In the first case, we set all of the fields to check that the record has been created. In the second case, we don’t set any of the fields to check that the record has been created.

Having arrived at this point, we can move all of the logic for field checking into the model tests, using, for example, Shoulda Matchers.

require 'spec_helper'

RSpec.describe RealEstate, type: :model do
  it { is_expected.to validate_presence_of(:mandatory_field) }
  it { is_expected.to allow_value("a particular value").for(:another_field }
end

Setting things up in this way, it’s unlikely that we’ll need to the deal with the features again. Furthermore, it will be possible to check a new field using at most a few lines of code, making the tests a lot simpler to maintain.

Shared Examples

Using shared examples we can abstract different scenarios, allowing us to reuse the code across all of the application’s resources:

RSpec.shared_example `a resource you can create` do

  describe `Creating an entry` do
    scenario `with valid values` do
      new_page.load
      fields.each do |field, value|
        new_page.send("#{field.to_s}_field").set value
      end
      new_page.submit!

      expect(new_page).to have_notice
    end

    scenario `with invalid values` do
      new_page.load
      new_page.submit!

      expect(new_page).to have_alert
    end
  end
end

It is now possible for us to write a generic resource like this:

require 'rails_helper'

RSpec.feature `Managing RealEstates`, type: :feature do
  it_behaves_like `a resource you can create`
    given(:new_page) { Pages::RealEstates::New.new }
    given(:fields) do
      {
        field1: 'value1',
        field2: 'value2',
        field3: 'value3'
      }
    end
  end
end

Search filters

Every index of a data resource should contain a form that makes it possible to filter its associated records.

Here again, the approach of creating an individual scenario for each and every filter could lead to a great deal of avoidable toil.

Ultimately, there are three options:

  1. I want to see all of the records without passing any parameters (an index will usually behave this way by default)
  2. I want to pass all of the parameters and get a particular record
  3. I don’t want to get any records and I want to pass all of the parameters
describe `When I filter RealEstates` do
  given(:index_page) { Pages::RealEstates::Index.new }
  given(:record) { create(:real_estate, :with_all_fields) }

  before { index_page.load }

  scenario `withour any search field I find the record` do
    expect(index_page).to have_record(record)
  end

  scenario `with existing values I find the record` do
    # here i fill the form

    expect(index_page).to have_record(record)
  end

  scenario `with not existing values I do not find any record` do
    #here I fill the form

    expect(index_page).to_not have_record(record)
  end
end

The relevant controller will end up looking something like this:

class RealEstatesController < ApplicationController
  def index
    @query = RealEstatesQuery.new(params)
    @collection = @query.scope
    respond_with @collection
  end
end

Now, using more or less the same approach that we used with the creation of the real estate representation, we will delegate all of the resource’s scope methods.

So, we can do everything with integration testing?

The answer to this question is: “Yes, we can: but it requires a lot of effort and will make the code writing process rather tedious.”

I also ought to say that I had to go through many, many hours of writing, deleting and rewriting code before I could find a solution that was able to make testing almost fun.

If we never have to deal with the world of RSpec (or with that of testing in general) it will take us a lot of time to think up ways of structuring our single tests, and we will in all likelihood prefer to do manual checking using a browser.

Did you find this interesting?Know us better

Made with Middleman and DatoCMS, our CMS for static websites