Speed up Rails tests 10x by using PORO domain models

Learn how to speed up Rails tests 10x using PORO domain models to test your domain logic separately without loading Rails or doing database integration.

If you're like most Rails developers I know (including myself), you're probably used to writing "unit" tests in RSpec that load up the whole Rails framework before each test, which takes a few seconds to do, even if you're only testing one tiny thing.

This is of course annoying because it's hard to get into a flow state when you're always having to wait a few seconds to see the results of your test. What do you do during that few seconds? It's not a long enough time to work on something else, but it's too long to just idly sit there.

No more slow tests

One good way to get rid of the delayed-test-result problem is to separate your domain logic from your persistence logic, and then test your domain logic separately without loading Rails or doing database interation. This is much easier said than done. I hope to shed some light on the how in this post.

An example

What I'm going to do is show you a pretty simple Ruby class along with its tests, and then demonstrate how you might persist this class's objects to the database. My example will be incomplete because, perhaps like you, this way of testing is new to me and the path to doing this is not yet clear. Hopefully I'm metaphorically hacking away a few feet of trail and helping some people get a little further.

The domain model

The example I'll use is an Appointment class that knows how to calculate its own length based on the services for that appointment. Here's the class:

class Appointment 
  attr_accessor :start_time, :errors, :services

  def initialize
    @errors = []
    @services = []
  end

  def length
    @services.collect(&:length_in_minutes).inject(0, :+)
  end
end

Just ignore @errors part for now. I'll explain that later. Now here's the spec for theAppointment class:

The spec

# Notice the absence of "require 'spec_helper'." We're only
# including the file for the class we're wanting to test.
require File.dirname(__FILE__) + "/../../app/models/appointment"

describe Appointment do
  before do
    @appointment = Appointment.new
    @appointment.start_time = "2000-01-01 00:00:00"
  end

  it "can have services added to it" do
    service = Object.new
    @appointment.services << service

    expect(@appointment.services).to eq([service])
  end

  describe "#length" do
    it "is the sum of all its services' lengths" do
      [FIRST_SERVICE_LENGTH = 30, SECOND_SERVICE_LENGTH = 45].each do |length|
        @appointment.services << service_stub_with_length(length)
      end

      expect(@appointment.length).to eq(FIRST_SERVICE_LENGTH + SECOND_SERVICE_LENGTH)
    end

    it "is 0 when there are no services" do
      @appointment.services = []

      expect(@appointment.length).to eq(0)
    end
  end

  def service_stub_with_length(length)
    service = double()
    service.stub(:length_in_minutes) { length }
    service
  end
end

There's also this other tiny class, Service:

class Service
  attr_accessor :name, :price, :length_in_minutes
end

It of course does almost nothing.

If you run the spec for Appointment, it takes barely any time at all. For me, if I puttime around the command, it takes just over a second, and adding more tests doesn't make the whole test run meaningfully slower. You can have 10 or presumably 1000 tests like this and still see the results almost immediately. This allows you to get into and stay in a flow state.

Here's what a view would look like

It's neat that these classes I wrote can be tested quickly without loading Rails, but this whole example is kind of meaningless so far since you can't save anything to the database. Let's do that next.

Here's a form that could be used to save a new appointment. Notice how it's just a form_tag and not a form_for:

<%= form_tag(create_appointment_path) %>

  <% if @appointment.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@appointment.errors.count, "error") %> prohibited this appointment from being saved:</h2>

      <ul>
      <% @appointment.errors.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= datetime_field_tag :start_time, "", placeholder: "Start Time" %>
  </div>

  <%= submit_tag "Create appointment" %>
</form>

And here's the controller

class AppointmentsController < ApplicationController 
  def index
    @appointments = Perpetuity[Appointment].all
  end

  def new
    @appointment = Appointment.new
  end

  def create
    # Create a PORO from params
    @appointment = Appointment.new
    @appointment.start_time = params[:start_time]

    # Instantiate a validator for the appointment
    # (which itself is incidentally also a PORO)
    validator = AppointmentValidator.new(@appointment)

    # If @appointment is valid, save it using the
    # Perpetuity gem, an implementation of the Data
    # Mapper ORM pattern
    if validator.validate
      Perpetuity[Appointment].insert(@appointment)
      redirect_to action: 'index'
    else
      @appointment.errors = validator.errors
      render action: 'new'
    end
  end

  private

  def prospective_user_params
    params.require(:appointment).permit(:start_time)
  end
end

In closing

I don't think the details of how AppointmentValidator or Perpetuity work are necessarily terribly important to explain here. What I want to demonstrate is that POROs can be plugged into Rails in a way that's not necessarily totally awkward. I'm sure I'll start running into more challenges as I start separating domain layers from persistence layers more in production projects, but I think for now this is a pretty neat proof of concept.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Starr Horne

    Starr Horne is a Rubyist and Chief JavaScripter at Honeybadger.io. When she's not neck-deep in other people's bugs, she enjoys making furniture with traditional hand-tools, reading history and brewing beer in her garage in Seattle.

    More articles by Starr Horne
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial