Unit and Integration Tests: The Gems We Use to Write Solid Applications
There are many types of tests, but we'll only consider integration (or functional) tests, which verify that the software actually does what it's supposed to, and unit tests, which verify that each unit of code works correctly.
Following up on the previous article, I'd like to continue on the topic of testing, this time describing the tools and methodologies we use at Cantiere Creativo.
Integration tests
Integration tests are "high-level" tests in which we "put ourselves in the shoes" of the user who is actually using the application and trying to get a result in response to a certain action.
This kind of test checks not only the correct behavior of each individual object, but also its relationships with the other components of the application.
In web development practice, an integration test — using gems such as Capybara — drives a real browser to programmatically perform a set number of actions on pages (submitting a form, for example), then checks for a specific visual output at the end of the test. Following the form submission example, it would expect a success message.
Although integration tests are undoubtedly the most thorough and reliable, because they run through the entire stack they're also the slowest, especially if the feature under test requires JavaScript interaction: we're talking 3-5 seconds per test.
Mid-sized applications quickly accumulate hundreds of tests, so a suite written entirely as integration tests is not advisable because of its execution time: we're talking tens of minutes!
The guideline we follow at Cantiere is to start by defining the happy path with an integration test, then specify the application's behavior in every failure case with unit tests on the specific layer, with the goal of cutting execution time by an order of magnitude.
Unit testing
A unit test focuses on a single unit of code and, by reproducing the necessary conditions, verifies its behavior in case of errors or outside the normal usage path.
Of course, the distinction isn't that clear-cut: in some components of the application it makes sense to use unit tests for the happy path too, such as Command and Query objects, which I'll cover in an upcoming post.
Unit tests are also especially useful when designing a software component, precisely because of their specificity: shaping a class or an interface through TDD with unit tests helps you respect the encapsulation principle and avoid coupling, thereby maximizing code reusability.
This kind of test should be considered "disposable", at least as far as the happy path is concerned, since, as mentioned above, it will probably be covered by higher-level tests.
Tools
Finally, let's get practical and talk about the gems that help us put the concepts in this article into practice.
SitePrism
SitePrism implements the Page Object Pattern.
The Page Object pattern represents the screens of your web app as a series of objects
A Page Object is an object that sits between our tests and the real page. This additional abstraction layer lets us encapsulate interactions with the real page in methods and make our tests more independent of its structure. The result is a DRYer test suite and, as a consequence, better maintainability.
Take, for example, a user who wants to log in to our application.
Without Page Objects we would have to write:
feature 'As an user, in order to see my dashboard' do
let(:user) { create(:user, email: email, password: password) }
let(:email) { 'user@example.com' }
let(:password) { 'foobar' }
before do
user # create user
end
scenario 'I want to sign in' do
visit '/sessions/new'
within("#session") do
fill_in 'Login', :with => email
fill_in 'Password', :with => password
end
click_button 'Sign in'
expect(page).to have_content t('user.signin.success')
end
endWith SitePrism it becomes:
class SignInPage < Page
set_url '/session/new'
element :email, "input[name$='[email]']"
element :password, "input[name$='[password]']"
element :submit_button, "input[type='submit']"
def sign_in_as(user, password)
email_field.set user.email
password_field.set password
submit_button.click
end
end
feature 'As an user, in order to see my dashboard' do
let(:sign_in_page) { SignInPage.new }
let(:password) { 'foobar' }
scenario 'I want to sign in' do
user = create(:user, password: password)
sign_in_page.load
sign_in_page.sign_in_as(user, password)
expect(sign_in_page).to have_content t('user.signin.success')
end
endNow suppose we want to add a 'remember me' checkbox to our login form. Without Page Objects we would have to change every single test that requires 'remember me' to be "checked", whereas with SitePrism we only need to change our Page Object:
class SignInPage < Page
set_url '/session/new'
element :email, "input[name$='[email]']"
element :password, "input[name$='[password]']"
element :remember_me, "input[name$='[remember]']"
element :submit_button, "input[type='submit']"
def sign_in_as(user, password, remember=true)
email_field.set user.email
password_field.set password
remember_me_field.set(remember)
submit_button.click
end
endUsing Page Objects means writing less code (especially when refactoring), reducing the chance of introducing new bugs and saving us time.
Tedium
Stefano Verna (@steffoz) created the gem Tedium, based on SitePrism, which makes interacting with forms even simpler. With Tedium, the previous example becomes:
class SignInPage < SitePrism::Page
set_url '/session/new'
fields :email, :password
submit_button
submission :sign_in
end
feature 'As an user, in order to see my dashboard' do
let(:sign_in_page) { SignInPage.new }
scenario 'I want to sign in' do
user = create(:user)
sign_in_page.load
sign_in_page.sign_in!(user.email, 'foobar')
expect(sign_in_page).to have_content t('user.signin.success')
end
endWith Tedium we can skip writing the selector for every single field, as well as the code needed to submit the form.
SimpleCov
SimpleCov collects data on our test coverage. In other words, it tells us which parts of our application's code, and how much of it, the tests executed, giving us a rough estimate of how well tested our application is.
An interesting feature of SimpleCov is report generation, which gives us visual feedback on our code coverage.
https://colszowka.github.io/simplecov/devise_result-0.5.3.pnghttps://colszowka.github.io/simplecov/devise_source_file-0.5.3.png
This is especially useful for making sure we haven't forgotten to test some execution path.
At Cantiere we try to keep coverage above 95% while avoiding redundant tests (ideally, each test should execute one and only one specific portion of code).
Conclusions
In this article I hope I've given you an idea of our approach to testing our products, and the reasons behind these choices. The aim is to show that, with the right approach and tools, you can add value to your applications without too much effort.