Smart Integration Testing
When it comes to testing, the conversation always ends up on integration tests. But are they really our favorite superhero? Or can they become our worst enemy? Using a practical example, let's see how far we can push integration tests and when we should delegate functional testing to other parts of the application
Smart integration tests with RSpec
I recently worked on a project for a management system. Nothing more standard: an admin back-end and a public front-end.
One model, though, caught my attention: the representation of a property.
The db table has about 60 attributes. A lot, I know. In cases like this you can split the model into several submodels:
class Immobile < ActiveRecord::Base has_one :superficie has_one :anagrafica_proprietario ... end
This breakdown may lighten the model, but it brings some problems with forms... but we'll talk about that another time.
Either way, the 60 fields are still there.
Integration tests
This project was relatively simple, so I used it as a testbed to push my use of integration tests a bit further.
The experiment I wanted to try was an approach focused almost entirely on integration tests that, with just a few lines of test code, could cover a good percentage of the business code.
The first thing to do is think about the scenarios to test. A first approach was the following (using the Pages module of the SitePrism gem):
require 'rails_helper'
RSpec.feature `Managing immobili`, type: :feature do
describe `Creating an entry` do
given(:new_page) { Pagine::Immobili::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
endNow, in a pure BDD approach, every line of code should be preceded by a test that justifies its existence.
If we wanted to follow this pattern using only integration tests, we'd have to replicate this code for every field (or at least for every field that needs some kind of validation)
And that could happen soon! When it does, the test code can become tedious to maintain, with the risk of giving up on writing tests altogether.
Can we do better?
Let's think for a second about how the controller action is built, since in our code it's the point that represents the same actions a user can perform in our application:
class ImmobiliController < ApplicationController
def create
@resource = Immobile.create(permitted_params)
respond_to @resource
end
endLooking at the controller, we realize that only 2 things can happen: the creation succeeds or it fails.
On top of that, by using gems like SimpleForm and Responders, we can take for granted that flash messages are set correctly based on the outcome of the action.
As a result, we can picture just 2 scenarios:
require 'rails_helper'
RSpec.feature `Managing immobili`, type: :feature do
describe `Creating an entry` do
given(:new_page) { Pagine::Immobili::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
endIn the first, we set all the fields to check that the record is created. In the second, we set none of them to check that the record is not created.
At this point, we move all the field validation logic into the model tests, using, for example, Shoulda Matchers
require 'spec_helper'
RSpec.describe Immobile, type: :model do
it { is_expected.to validate_presence_of(:mandatory_field) }
it { is_expected.to allow_value("a particular value").for(:another_field }
endWith a setup like this, we'll rarely need to touch the features again. Plus, validating a new field comes down to one (or very few) lines of code, making the code easier to maintain.
With shared examples, we can abstract the scenarios so we can reuse them across all 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
endNow we can write a generic resource like this:
require 'rails_helper'
RSpec.feature `Managing Immobili`, type: :feature do
it_behaves_like `a resource you can create`
given(:new_page) { Pagine::Immobili::New.new }
given(:fields) do
{
field1: 'value1',
field2: 'value2',
field3: 'value3'
}
end
end
endSearch filters
Every index of a given resource should include a form for filtering its records.
Here too, creating a scenario for every filter gets heavy.
In the end, there are usually 3 scenarios:
- Without passing any parameters, I want to see all records (this is usually an index's default behavior)
- Passing all parameters, I want to get a specific record
- Passing all parameters, I want to get no records
describe `When I filter Immobili` do
given(:index_page) { Pages::Immobili::Index.new }
given(:record) { create(:immobile, :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
endThe corresponding controller will look something like this:
class ImmobiliController < ApplicationController
def index
@query = ImmobiliQuery.new(params)
@collection = @query.scope
respond_with @collection
end
endNow, along the same lines as what we did for creating a property, we delegate all of our resource's scope methods to the query object
So, can everything be done with integration tests?
At this point the answer would be yes, but it would take considerable effort and make the code tedious to write.
I have to say, though, that I spent many, many hours writing, deleting and rewriting code to find a solution that made testing almost fun.
If we never venture into the world of RSpec (or testing in general), we'll spend a lot of time figuring out how to structure each single test, and keep preferring manual checks in the browser.