Writing Sensible Tests for Happiness

on
| 15 min read

Writing good, sensible tests is hard. As a Rubyist, I feel lucky to be part of a community that embraces tests. Though at the same time, I have come across too many projects that suffered from not having sensible tests.

What are Sensible Tests?

There often isn’t a silver bullet when it comes to software development. Technical stuff aside, many things contribute to the solution to a given problem - the team, the project and the business to name a few. This article does not attempt to present any insights into the best practices for testing, rather it collects a few tips I believe would benefit those who are not yet comfortable with writing tests.

To me, sensible tests often have the following characteristics:

  • it does not replicate implementation details;
  • it does not provide false sense of security;
  • it runs reasonably quickly;
  • it does not slow down the development significantly;
  • it guides the programmer towards a better architecture;
  • and, it does not make you sigh every time you want to modify your tests.

Art and Science, TDD or Not TDD

Just like writing production code, writing tests is also a combined form of art and science. It takes not only experience, but also intuition to write sensible tests. You have to remember that not all projects and programmers are equal - take what you get, practise, and reflect on your findings.

Many times I had come across seasoned programmers practising TDD, only to find themselves cornered into a bad design that ultimately had to be thrown away. TDD does not save you from writing bad code, this article is not about TDD, it’s about testing in general.

I am most comfortable with using RSpec, FactoryGirl, Capybara and Turnip, so I’m going to use these tools in the code. The principles however apply to any testing framework.

Test as Little as Possible to Reach a Given Level of Confidence

Kent Beck, the inventor (or more correctly, ‘rediscoverer’) of TDD once said:

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence.

I used to prefer testing almost everything, but over the recent years I find myself increasingly look for key areas of the system that need the test coverage the most. Typically, our systems would have:

  • unit and functional tests for model behaviours
  • unit and functional tests for services
  • integration tests for controller actions
  • request tests for API endpoints
  • isolated JavaScript tests
  • high level integration/acceptance tests in Gherkin

Model and service level tests are arguably the most important ones so we make sure we have really good test coverage for those. For controller tests we rely heavily on reusable production and test code for maintainability and sanity. For API endpoints we mostly test presented data structure - as business logic and data integrity should have been covered in model, service and controller layers. Isolated JavaScript tests take care of both presentational business logic and tricky UI tasks. And finally, acceptance tests handle happy-path user interactions.

Do Not Test Framework and Library Code

Writing application-specific business logic is difficult enough, you really should not test functionalities provided by the framework or libraries. Below is an example of such bad tests:

describe ApprovalStakeholder do
  it { should belong_to(:approval) }
  it { should_not validate_presence_of(:approval) }
end

Similarly to how you would add useful comments, i.e. describe why instead of what, these tests should be replaced by tests that cover actual functionalities, for instance the reason why an ApprovalStakeholder doesn’t need an Approval to be presence should be demonstrated in the tests:

shared_examples_for "non-approval specific stakeholder" do
  its(:action_that_does_not_care_about_approval) { should be_true }
end

describe ApprovalStakeholder do
  let(:approval) { create(:approval) }
  let(:user) { create(:user) }
  let(:role) { create(:role) }

  subject do
    build(:approval_stakeholder,
      :user_id => user.id,
      :role_id => role.id
    )
  end

  context "with an approval" do
    before { subject.approval = approval }

    it_behaves_like "non-approval specific stakeholder"

    its(:action_that_does_care_about_approval) { should be_true }
  end

  context "without an approval" do
    it_behaves_like "non-approval specific stakeholder"

    its(:action_that_does_care_about_approval) { should be_false }
  end
end

Ensure What You are Testing Makes Sense

The test case below showcases the original developer’s lack of attention and awareness on designing a functional and secure system. It actually tests the reference keys for the ApprovalStakeholder object are allowed to be mass assignable, which is a recipe for disaster.

describe ApprovalStakeholder do
  it { should allow_mass_assignment_of(:user_id) }
  it { should allow_mass_assignment_of(:role_id) }
end

De-Duplicate Test Cases

Looking at the example below, the first thing you’d notice is the amount of duplication.

describe ApprovalStakeholder do
  it "#traveller" do
    stakeholder = create(:approval_stakeholder,
      :approval => approval,
      :user_id => traveller.id
    )
    stakeholder.stub(:user).and_return(traveller)
    approval.stub(:stakeholders_as).and_return([stakeholder])

    approval.traveller.should == traveller
  end

  it "#authoriser" do
    stakeholder = create(:approval_stakeholder,
      :approval => approval,
      :user_id => authoriser.id
    )
    stakeholder.stub(:user).and_return(authoriser)
    approval.stub(:stakeholders_as).and_return([stakeholder])

    approval.authoriser.should == authoriser
  end
end

It’s true that tests act as a form of specification therefore should be optimised for clarity, in this case however, we could still maintain the clarity with significantly reduced duplication:

describe ApprovalStakeholder do
  let(:stakeholder) do
    create(:approval_stakeholder,
      :approval => approval,
      :user_id => user.id
    )
  end

  subject { approval }

  before do
    stakeholder.stub(:user).and_return(user)
    approval.stub(:stakeholders_as).and_return([stakeholder])
  end

  describe "#traveller" do
    let(:user) { traveller }

    its(:traveller) { should == traveller }
  end

  describe "#authoriser" do
    let(:user) { authoriser }

    its(:authoriser) { should == authoriser }
  end
end

Do Not Replicate Implementation Details

I am often surprised to see many seasoned developers “enjoy” writing tests that essentially replicate the production code logic without much benefit. See below:

describe ApprovalStakeholder do
  it "references a user" do
    approval_stakeholder = build :approval_stakeholder, :user_id => 1
    User.should_receive(:find).with(1)
    approval_stakeholder.user
  end

  it "references a role" do
    approval_stakeholder = build :approval_stakeholder, :role_id => 1
    Role.should_receive(:find).with(1)
    approval_stakeholder.role
  end
end

Rather than creating noisy tests, tests with actual assertions seem much more meaningful and readable:

describe ApprovalStakeholder do
  subject do
    build(:approval_stakeholder,
      :approval => approval,
      :user_id => user.id,
      :role_id => role.id,
    )
  end

  its(:name) { should == "#{user.first_name} #{user.last_name}" }
  its(:role_name) { should == role.name }
end

Reduce the Reliance on Mocks and Stubs

This is a difficult and often-debated subject. In my experience, having too many mocks and stubs even though speeds up the test suite, usually leaves too many holes in your tests and makes the test suite less accurate and effective. Fortunately, by using more service objects (described below), mocking and stubbing become more manageable as you use them mostly on external objects and interfaces.

Take Apart the System, One Service at a Time

If you’re a Rails developer, you are already familiar with MVC. But just relying on MVC to hold your application architecture is probably not going to be sufficient for an average modern day web application. Many people like Service-oriented architecture, so do I.

Services are unassociated, loosely coupled units of functionality that are self-contained.

In my experience, as long as you are disciplined in having services do one and only one thing really well, testing becomes much easier.

For instance, we have a Bouncer service that is responsible for safeguarding resources - ensuring read-only attributes don’t get overridden.

module Services
  class Bouncer
    def self.guard(resource, options = {})
      if options[:existing_resource]
        resource.readonly_attributes.each do |attr_name|
          resource.send("#{attr_name}=", options[:existing_resource].send(attr_name))
        end
      end

      resource
    end
  end
end

The corresponding tests for this service are both fast and self-contained:

describe Services::Bouncer do
  class BouncerDude
    include Mos::Entity

    set_readonly_attributes :age, :gender

    attribute :name
    attribute :age
    attribute :gender
  end

  let(:resource) { BouncerDude.new(name: 'Penny', age: 28, gender: 'female') }
  let(:existing_resource) { BouncerDude.new(name: 'Sheldon Cooper', age: 34, gender: 'male') }
  subject { Services::Bouncer.guard(resource, existing_resource: existing_resource) }

  describe "#guard" do
    its(:name) { should == 'Penny' }
    its(:age) { should == 34 }
    its(:gender) { should == 'male' }
  end
end

Recognise Common Patterns and Refactor Them into Services

One of the reasons why service-oriented architecture is so popular is because things are broken down into smaller, more manageable and more testable pieces. It is especially helpful for TDD practitioners as it significantly reduces the amount of coupling between your production code and your tests due to having simpler internals per test subject.

Take a look at the below example, which is hard to read, hard to test and error-prone:

module ApplicationHelper
  def branch_logo_options(branch)
    BranchLogo.where(branch_id: branch.id).map { |logo| [logo.file, logo.id] }
  end

  def branch_options(agency)
    BranchRepository.find(agency_id: agency.id, archived: false).map do |b|
      [b.name, b.id]
    end
  end

  def agency_user_options(agency, filtered_users)
    filtered_user_ids = filtered_users.compact.map(&:id) || []
    AgencyUserRepository.find(agency_id: agency.id, archived: false).select do |u|
      !filtered_user_ids.include?(u.id)
    end.map { |u| [u.full_name, u.id] }
  end

  def current_agency_user_options(filtered_users = [])
    agency_user_options(current_agency, filtered_users)
  end

  def current_agency_trust_bank_account_options
    BankAccountRepository.find(
      agency_id: current_agency.id,
      archived: false,
      account_type: BankAccount::TRUST_ACCOUNT).map do |b|
      [b.account_name, b.id]
    end
  end

  def code_options_for(klass)
    klass.all.map { |cc| ["#{cc.code} - #{cc.name}", cc.id] }.sort
  end
end

Let’s refactor it into something more manageable, by introducing a service ShowGirl for fetching and presenting data collections:

module CollectionOptionsHelper
  def branch_logo_options(branch)
    Services::ShowGirl.present(branch, from: BranchLogo, show: :file)
  end

  def branch_options
    Services::ShowGirl.present(current_agency, from: BranchRepository)
  end

  def consultant_options(excluded_users = [])
    Services::ShowGirl.present(
      current_agency,
      from: AgencyUserRepository,
      show: :full_name
    ) do |collection|
      collection.reject { |user| user.id.in?(Array.wrap(excluded_users).map(&:id)) }
    end
  end

  def trust_bank_account_options
    Services::ShowGirl.present(
      current_agency,
      from: BankAccountRepository,
      show: :account_name,
      filters: { account_type: BankAccount::TRUST_ACCOUNT },
    )
  end

  def code_options_for(name)
    Services::ShowGirl.present(
      current_agency,
      from: Admin::Configurations::Essential.descendants.find { |d| d.name =~ /::#{name.to_s.classify}/ },
      show: -> (item) { "#{item.code} - #{item.name}" }
    )
  end
end

Better yet, we can clean it up even further by introducing another service, BusBoy for just serving the data, and leaving ShowGirl for only presenting the data:

module CollectionOptionsHelper
  def branch_logo_options(branch)
    Services::ShowGirl.present(
      Services::BusBoy.serve(:branch_logos, branch: branch)
    )
  end

  def branch_options
    Services::ShowGirl.present(
      Services::BusBoy.serve(:branches, agency: current_agency)
    )
  end

  def consultant_options(excluded_users = [])
    Services::ShowGirl.present(
      Services::BusBoy.serve(:consultants, agency: current_agency),
      show: :full_name
    ) do |collection|
      collection.reject { |user| user.id.in?(Array.wrap(excluded_users).map(&:id)) }
    end
  end

  def trust_bank_account_options(account_type)
    Services::ShowGirl.present(
      Services::BusBoy.serve(:bank_accounts,
        { agency: current_agency, BankAccount::TRUST_ACCOUNT }
      ),
      show: :account_name
    )
  end

  def code_options_for(name, options = {})
    Services::ShowGirl.present(
      Services::BusBoy.serve(name, agency: current_agency), options
    )
  end
end

Basic Controller CRUD Actions

In one of our projects we have lots and lots of forms. Consequently we have lots and lots of CRUD actions. In order to keep our sanity as well as to make basic CRUD controllers maintainable, we have a custom DSL to make CRUD actions portable and testable:

module Profiles
  class TravellersController < BaseController
    authorize_resource class: Traveller

    datamappify_resources entity: Traveller,
                          repository: TravellerRepository,
                          filter_by: :agency_id,
                          filter_value: -> { current_user.agency_id }
  end
end

Most of our controller tests look like this:

require 'spec_helper'

describe Profiles::AccountsController do
  let(:existing_resources) { [] }
  let(:create_resource) { Mos::Data.create_account }
  let(:create_resources) { Mos::Data.create_accounts(2) }
  let(:a_resource) { assigns(:resource) }
  let(:invalid_param) { { name: '' } }
  let(:params_key) { :account }
  let(:redirect_path) { profiles_accounts_path }

  it_behaves_like 'datamappify resources controller'
  it_behaves_like 'searchable resources controller', :name,
                                                      :profile_id,
                                                      :branch_id,
                                                      :activated

  describe "permission" do
    context 'as a manager' do
      before do
        sign_in_as :manager
      end

      it_behaves_like 'with write access'
      it_behaves_like 'with read access'
      it_behaves_like 'with index access'
    end

    context 'as a consultant' do
      before do
        sign_in_as :consultant
      end

      it_behaves_like 'without write access'
      it_behaves_like 'with read access'
      it_behaves_like 'with index access'
    end
  end
end

API Endpoint Tests

One of our projects at work is an API service that is essential to our platform. Naturally, we not only need to test the models, services and controllers, we also need to ensure the API endpoints do what they are supposed to do - mostly exposing the correct data structure.

During the early stage of the development, I had come up with ApiTaster - a super useful gem for visually testing our Rails application’s APIs. Later on, as we continued to grow our API endpoints, we started utilising ApiTaster for our automated test suite too.

In essence, we have one API spec file responsible for describing which endpoints are tested and missed according to the information given by ApiTaster:

describe "API" do
  load 'db/seeds.rb'
  load 'spec/api_endpoints.rb'

  ApiTaster::Route.map_routes

  ApiTaster::Route.defined_definitions.each do |route|
    it "api endpoint #{route[:verb]} #{route[:path]}" do
      params = ApiTaster::Route.params_for(route).first
      expectation = ApiTaster::Route.metadata_for(route)[:expectation]
      setup = ApiTaster::Route.metadata_for(route)[:setup]
      verb = route[:verb].downcase
      path = parse_path_with_url_params(route[:path], params[:url_params])

      setup.call if setup

      send verb, path, params[:post_params]

      response.body.should match_json_expression(expectation)
    end
  end

  # warn about undefined definitions
  ApiTaster::Route.missing_definitions.each do |route|
    pending "api endpoint #{route[:verb]} #{route[:path]}"
  end
end

Then, we have a bunch of endpoint test files to do the actual testing, like this:

resource_response = ResponseHash[
  :response => {
    :id => Integer,
    :name => String,
    :token => String
  }
]

get '/:version/company', {}, {
  :expectation => resource_response
}

post '/:version/companies', {
  :model => FactoryGirl.attributes_for(:company)
}, {
  :expectation => resource_response
}

put '/:version/companies/:id', {
  :id => 1,
  :model => { :name => 'New Company' }
}, {
  :expectation => resource_response.with(:name => 'New Company')
}

delete '/:version/companies/:id', {
  :id => 1
}, {
  :expectation => resource_response
}

Notice that for API endpoint tests we don’t test the business logic or data integrity - these should be tested in models, services and controllers. What we do test are correct endpoints are exposed, correct parameters are accepted and correct data structures are returned.

Isolated JavaScript Tests

Many developers prefer to rely on their integration test suite to do JavaScript / UI testing. This approach is fine until you start making lots of front-end changes and constantly need to pinpoint the relevant feature spec.

Having an isolated JavaScript test suite (which should be run as part of your continuous integration process) is extremely beneficial and often saves debugging time.

I like Mocha so we use Konacha in our Rails app. Though Mocha with Chai is really not that different to Jasmine.

Custom JavaScript behaviour is obviously a good candidate for isolated testing:

#= require spec_helper

describe "form toggle", ->
  beforeEach ->
    $("body").append(JST["templates/form/toggle"])

  it "hides the collapsible field by default", ->
    $(".control-group.branch_deactivation_date").hasClass('in').should.be.false

  it "does not override if there is already a value", ->
    value = $("input#agency_deactivation_date").val()
    $("input#agency_activated").click()
    $("input#agency_deactivation_date").val().should.equal(value)

Sometimes it’s also useful to ensure library code is initiated and triggered correctly, if you have other custom JS interact with it:

#= require spec_helper
#= require bootstrap-datepicker

describe "form dates", ->
  beforeEach ->
    @dateFormat = 'DD/MM/YYYY'
    $("body").append(JST["templates/form/dates"](dateFormat: @dateFormat))

  it "has a placeholder", ->
    $("input").attr("placeholder").should.equal(@dateFormat)

  it "defaults to today's date", ->
    $("input#empty").focus()
    $("input#empty").focus()
    $("input#empty").val().should.equal(moment().format(@dateFormat))

  it "does not override if there is already a value", ->
    value = $("input#filled").val()
    $("input#filled").focus()
    $("input#filled").val().should.equal(value)

“Real” UI Tests

Isolated JavaScript tests are super fast and useful. However, there are times when having pure JavaScript tests simply isn’t enough, due to the complicated nature of DOM interaction and template rendering.

A while ago our calendar widget was broken due to a production and UAT environment issue that was not picked up by our JavaScript test suite. Since then we started adding dedicated UI tests in our acceptance test suite (we use Turnip):

@ui
Feature: UI
  Background:
    Given I am signed in
      And I go to agency consultants page
      And I click on "Add New Consultant"

  Scenario: Calendar
      When I click "#agency_user_start_date"
      And I click ".day.active" within ".datepicker"
      Then I should see today as part of the date field

Effective Acceptance Tests

Writing acceptance tests - also known to many Rubyists as “Cucumber tests”, is a double-edged sword - it’s extremely useful, but very few developers can write good, maintainable Gherkin-style acceptance tests.

Here’s an example of a badly written feature spec with too much implementation details and noise:

Feature: Session
  Background:
    Given I visit "/"
      And there is a user "admin" "password"

  Scenario: Sign in with valid credentials
      When I fill in "Username" with "admin"
      And I fill in "Password" with "password"
      And I click "Sign In"
      Then I should be on "/dashboard"

  Scenario: Sign in with invalid credentials
      When I fill in "Username" with "admin"
      And I fill in "Password" with "invalid_password"
      And I click "Sign In"
      Then I should not be on "/dashboard"

  Scenario: Sign out
      When I fill in "Username" with "admin"
      And I fill in "Password" with "password"
      And I click "Sign In"
      And I click "Sign Out"
      Then I should be on "/sign_in"

A much cleaner version with only high level, descriptive steps:

Feature: Session
  Background:
    Given I am on the homepage
      And there is a user "admin" with password "password"

  Scenario: Sign in with valid credentials
      When I sign in as "admin" with password "password"
      Then I should be signed in

  Scenario: Sign in with invalid credentials
      When I sign in as "admin" with password "invalid_password"
      Then I should not be signed in

  Scenario: Sign out
    Given I am signed in as "admin" with password "password"
      When I sign out
      Then I should be signed out

Final Thoughts

Writing good, sensible tests is hard. These examples and tips are by no means the silver bullet, and you might actually find some of them counter-intuitive in your particular situation. So again, take what you get, practise, and reflect on your findings. For Happiness! :)

Do you have any tips to share? If so please feel free to add a few comments!

Enjoy what you are reading? Sign up for a better experience on Persumi.

Comments