We're working on something new! Hook Relay gives you Stripe-quality webhooks in minutes. Sign up for free today! Check out Hook Relay

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 = []

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

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"

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

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

  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)

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

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

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

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

There's also this other tiny class, Service:

class Service
  attr_accessor :name, :price, :length_in_minutes

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>

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

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

  <%= submit_tag "Create appointment" %>

And here's the controller

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

  def new
    @appointment = Appointment.new

  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
      redirect_to action: 'index'
      @appointment.errors = validator.errors
      render action: 'new'


  def prospective_user_params

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.

Honeybadger has your back when it counts. We're the only error tracker that combines exception monitoring, uptime monitoring, and cron monitoring into a single, simple to use platform.

Our mission: to tame production and make you a better, more productive developer. Learn more

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.

“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
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you’re paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days