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.

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
    An advertisement for Honeybadger that reads 'Turn your logs into events.'

    "Splunk-like querying without having to sell my kidneys? nice"

    That’s a direct quote from someone who just saw Honeybadger Insights. It’s a bit like Papertrail or DataDog—but with just the good parts and a reasonable price tag.

    Best of all, Insights logging is available on our free tier as part of a comprehensive monitoring suite including error tracking, uptime monitoring, status pages, and more.

    Start logging for FREE
    Simple 5-minute setup — No credit card required