Writing Specs without Rails for Legacy apps

In our very large Rails app (or MonoRail as we like to call it) we have a lot of specs, the majority of which require 'spec_helper', which in turn requires Rails. This is necessary for any specs which depend on the Rails framework like controller and acceptance tests.

But for many specs, such as unit test, where we test one object in isolation and intergration tests, where we test the interactions between multiple collaborating objects, there is no need to boot the entire Rails framework.

Apart from Rails being slow to boot the main advantage of not requiring it is that none of your application code is automatically required.

This means we have to manually require each dependency of the code we are testing. This in turn makes us more aware of the dependencies and relationships between our units of code.

The idea being that we want to decouple our core business logic, such as models and services, from the delivery mechanism (HTTP/HTML). This is the central idea behind Hexagonal Rails (aka Port and Adapters). By having a clear boundary between core business logic and the delivery mechanism(s) the core application can be wrapped in a Web UI, JSON API, native app, Asynchronous process or CLI.

A good first step and the new default for the rspec-rails gem is to have a separate spec_helper and rails_helper. The spec_helper file will not require Rails, the rails_helper will require Rails. This makes requiring of Rails and its auto loading magic opt-in.

In a legacy app this will move awareness of dependencies to the forefront and present opportunities for refactoring towards a Hexagonal architecture.

How

The first step is to rename your existing spec_helper to rails_helper and find/replace all instances of spec_helper with rails_helper. Your specs should still pass. Next create a new spec_helper file, copying across all, if any, none-Rails related stuff. Minimal is good, the closer to the following example the better:

# do not add anything here unless it is required by EVERY spec

RSpec.configure do |config|
end

Finally add require 'spec_helper' at the top of rails_helper.

Once merged let your team know:

  • spec_helper no longer boots Rails
  • If you need the entire Rails framework require rails_helper

A pleasant side effect is that tests which don’t boot Rails run faster giving a shorter feedback cycle when doing TDD.

The default for anything which is intrinsically dependent on Rails (e.g. controllers/helpers/views/routing) should use require 'rails_helper'.

Anything else; models, presenters, services etc. should use require 'spec_helper'.

Be proactive

If you touch a spec that requires rails_helper see if you can remove it and just use spec_helper.

Tips

Rails constant

By not booting Rails it means any references to the Rails constant such as Rails.env will break. Instead you can pass the environment in to the object from the outside.

def my_method(env = Rails.env)
  send_emails if env.production?
end

# in your spec
let(:env) { ActiveSupport::StringInquirer.new('production') }

it 'sends emails' do
  my_method(env)
end

}

require and LOAD_PATH

In specs without Rails none of the app directories are added to your LOAD_PATH which means a simple require 'user' will not work. Instead you can use require_relative, but this tends to ends up being ugly require_relative ../../../app/models/user.

Instead you can use the spec_requirer gem which adds methods which under the hood do a require_relative:

require_models     'user', 'property'
require_presenters 'user_presenter'

Here is an example configuration which you could add to spec_helper:

require 'spec_requirer'

APP_ROOT = Pathname(File.dirname(__FILE__)).join('..')

Spec::Runner.configure do
  components = %w(models services forms jobs queries validations presenters)

  SpecRequirer.setup(app_root: APP_ROOT.join('app'), components: components)

  include SpecRequirer

  # spec_requirer does not (yet) have support for requiring outside the app directory, so we do these manually.
  def require_initializer(name)
    require APP_ROOT.join('config', 'initializers', name)
  end

  def require_support(name)
    require APP_ROOT.join('spec', 'support', name)
  end

  def require_lib(name)
    require APP_ROOT.join('lib', name)
  end
end

Summary

We can better decouple our core business logic from our delivery mechanism if we become aware of our dependencies by explicitly requiring them.

The first step is for spec_helper to not boot Rails and its autoloading magic.

photo of Kris Leech

Software Engineer
comments powered by Disqus