Why Not Callbacks
The Problem
You often want something to happen at a stage in an ActiveRecord object’s standard workflow - most often this is “After I save I want to do X.”
For Example:
class File < ActiveRecord::Base
validates :file_path, :presence => true
end
File.save # => Persist, if successful,
# create a thumbnail version for the web.
Why Callbacks
An often recommended way to deal with this is as an after_save callback.
class File < ActiveRecord::Base
validates :file_path, :presence => true
after_save :create_thumbnail
private
def create_thumbnail
ThumbnailCreator.call(file_path)
end
end
PROs:
- Simple, concise, all the logic is in one place you can look at.
- You can never accidentally save a file without it creating a thumbnail.
Before I get to the CONs, let’s look at some assumptions made:
Assumptions:
- We’re okay opening up the file class whenever we want to change when and how we create a thumbnail.
- We ALWAYS want to create a thumbnail.
- If we save a file, we’re okay with whatever overhead exists to create that thumbnail.
- We’re okay with not injecting dependencies (ThumbnailCreator), and thus when that changes we’re happy to change it in File.
CONs:
- State is mixed up with business logic. Suddenly my “File” is not just “attributes with persistence”, and is now “attributes with persistence and side effects too”
- NOT creating a thumbnail is hard. How do I avoid callbacks? Only this SPECIFIC callback?
- Doing something like pushing that thumbnail creation to the background is complicated logic that will sit in the model.
- ThumbnailCreator is hard-wired, its responsibility is inflexible.
Why Not Callbacks
It turns out the assumption that we ALWAYS want to create a thumbnail is a big
one. It’s rare that you ALWAYS want something to happen, and building in that
assumption is the beginning to expensive backtracking or hacks later.
The big point is this: business logic changes at a different rate than object
representation. Tying the logic of when and how to create a thumbnail to the
representation of how a File is stored means coupling changes in the File to
changes in how a thumbnail is created, unnecessarily.
Alternatives
Service Objects
We can encapsulate what to do when an object is saved by putting all of it in one object.
class FileSaver
attr_reader :file, :thumbnail_creator
def initialize(file, thumbnail_creator)
@file = file
@thumbnail_creator = thumbnail_creator
end
def run
if file.save
thumbnail_creator.call(file.file_path)
end
end
end
This works - you can inject ThumbnailCreator, you only use the FileSaver when you want, and you will never accidentally create a thumbnail.
However, what tends to happen is that ALL behavior gets stored here and the problem of “how do I ignore just ONE callback action” remains.
Decoration of Functionality
We can decorate JUST this functionality onto the object.
class CreatesThumbnails < SimpleDelegator
attr_reader :thumbnail_creator
def initialize(file, thumbnail_creator)
@thumbnail_creator = thumbnail_creator
super(file)
end
def save
if __getobj__.save
thumbnail_creator.call(file_path)
end
end
end
CreatesThumbnails.new(File.new(:file_path => "/file"), ThumbnailCreator).save
Now when you want to create a thumbnail on save you can decorate the object with this, call save, and you’re good. If when or how to run the thumbnail creator changes, you can inject a new thumbnail creator or change just the CreatesThumbnails object. Business logic is now separated from state.
Decorating all the necessary actions and dependencies for a transaction can get complicated, at which point the Factory Pattern can help. I may do a post on this at a later date.
Avoiding Integration Tests
To see the previous post in this series please check out How I Used to Test
What Now?
Integration tests with Capybara had failed me and in my experience there was only one other option: Unit Tests.
My reasoning went like this: People had been automatically testing applications, even web applications, long before they could automatically launch a browser and click around with a clean DSL. How did they do it?
The answer is messages.
Trust Your Interface
Rather than testing that all the pieces hook together, you test that each of the parts (units) send the right messages to the right collaborators, and that those collaborators do what you expect them to do. The web, and Ruby on Rails, has a fairly specific workflow that you can trust. It goes like this:
URL In Bar -> Route -> Controller -> View
Fortunately, RSpec has tests for each of these layers.
Routing Tests
Testing that a route goes to the right place looks like this:
# spec/routing/post_routing_spec.rb
require 'rails_helper'
RSpec.describe "post routes" do
it "should route /posts/1 to the posts controller" do
expect(get "/posts/1").to route_to :controller => :posts, :action => :show, :id => "1"
end
end
If you run this and the controller doesn’t exist, it will tell you. However, it won’t tell you if the action doesn’t exist.
Controller Tests
Now (assuming the above test passes), you have a route that goes to a specific controller, an action, and sets a parameter. Time to write the controller spec, to make sure it behaves appropriately.
# spec/controllers/posts_controller_spec.rb
require 'rails_helper'
RSpec.describe PostsController do
describe "show" do
before do
@post = Post.create
get :show, :id => @post.id.to_s
end
# Ensures that @post is set for the view to use
it "should set @post" do
expect(assigns(:post)).to eq @post
end
it "should render the show template" do
expect(response).to render_template "posts/show"
end
end
end
This test will complain if a route doesn’t exist, if the action doesn’t exist, and will properly fail if either the instance variable or the template isn’t set.
View Specs
Now we know the controller responds to the right route, populates an instance variable, and renders the appropriate template. Now how do we prove that the template shows the right information?
Enter view specs.
# spec/views/posts/show.html.erb_spec.rb
require 'rails_helper'
RSpec.describe "posts/show.html.erb" do
let(:post) { Post.create(:title => "Test") }
before do
assign(:post, post)
render
end
it "should show the post's title" do
# This requires Capybara to be installed,
# since it's using its matchers.
expect(rendered).to have_content "Test"
end
end
What’s Wrong With This?
This leaves just a little bit of a hole - nothing right now actually enforces that these connections exist. At each layer you’re trusting that the other layer has tests to ensure that it’s doing the right thing. There’s likely room for some automated contract verification, but it doesn’t exist yet. A simple integration test which just fails if the pieces fall down is probably in order.
# spec/features/show_posts_spec.rb
require 'rails_helper'
feature "posts" do
background do
@post = Post.create(:title => "Test")
end
scenario "visiting a post page" do
expect{visit posts_path(@post)}.not_to raise_error
# May or may not be required depending on your Rails settings.
expect(page).not_to have_content "Exception"
end
end
Wait! We just wrote an integration test. Weren’t we avoiding that? Let’s go over why we avoided integration tests in the first place:
The Goals
Tests that are:
- Fast
- Predictable
- Informative
- Reliable
There is only one feature spec - and there will always be only one feature spec for a given feature. Reducing this number to 1 means that tests will be fast and there’s very little chance the browser will get in the way.
Now we’re left with:
FastPredictable- Informative
Reliable
Informative
Test Driven Development is supposed to drive good design of an application. We have a fast test suite now, but at no point did the tests actually warn us that we may be doing something bad. For instance, the PostsController could look like this:
class PostsController
def show
@post = post_factory.new
end
private
def post_finder
PostFinder
end
def post_factory
PostFactoryFactory.new(post_finder.new(self, params, :with_cached))
end
end
The tests wouldn’t complain, so long as those classes exist. The test would be easy to write, and just as concise.
The reason is this: the complexity in programming comes from branches and
dependencies. In these tests we’ve hidden away that dependencies exist - if
tests are written such that they tell us about them then the complexity becomes
apparent while writing the test.
Recommended Watching
Integrated Tests Are A Scam - Great talk by J.B. Rainsberger about why integrated tests eventually calcify and his proposal on what to do about it.
Up Next
Next Post in Series: Isolated Testing
How I Used to Test
Starting Out
I’ll always remember my first interview for a real development position. It was for a developer position here at Oregon State University as a PHP programmer. I was asked to report on a project I had completed in the past and then asked a series of questions about how I develop. The one that stands out the most went like so:
Interviewer: How do you test that your applications work?
There was only one answer, obviously.
Me: Well, first I open the app in a browser and I...
You can see where I was going. It wasn’t the answer they were looking for and I could feel it. Time to figure out what went wrong.
Dipping The Toes
I got my first full time job shortly afterwards, having answered a similar question by saying “well, I’m familiar with Test Driven Development, but I’ve never done it.”
Fortunately they were nice.
I knew I needed to figure out how to test, and they had me learning Ruby, so it was time to explore the options:
Potential Tools
- Test::Unit
- RSpec
- Capybara
- Cucumber
Alright, lots of people are using RSpec, I quickly decided I didn’t like Cucumber, and then…there was Capybara.
Capybara
Capybara is a testing framework in ruby which tests web applications by
simulating a browser. It goes to pages, clicks through forms, and validates that
text is on the page. An automated Capybara test (back then) looked like this:
require 'spec_helper'
describe "viewing posts" do
before do
@post = Post.create(:title => "My Title")
visit "/posts"
end
it "should display all the post titles" do
expect(page).to have_content("My Title")
end
end
Finally something which automated everything I would normally do by hand! Things were taking seconds, rather than minutes, to make sure they work. If I wrote a whole suite of these Integration tests, then I could be sure the site was working and could refactor my code at will.
Some Problems
A few little problems popped up.
-
Testing Was Slow
Testing my application was taking something like 30-60 minutes to run through the full set of tests.
Oh well, I could work around that - throw in a Rails preloader like spring, run only the specs I care about while developing, and then things only hurt when I had to make sure the all the tests were done. Solved
-
Test Driven Development
I kept reading that I should be writing tests first, and it was hard. Often times the last thing I knew was what the UI would look like - how could I write a test that included information about it? Oh well, do my best. Not So Solved
-
Code Design
This felt like the biggest problem to me - I kept hearing that tests should make me recognize when my code needed help, but none of my tests were saying anything about the code - just the application that it created. I could refactor without changing tests, yes, but I could also NOT and it would all feel the same. Not Solved
-
Unreliable
Sometimes I’d run my tests and they’d fail - but seemingly randomly. I couldn’t trust the application unless they passed, but there was no way to find out why they were failing (often times it’d be a race condition within capybara or my application logic that couldn’t be reproduced by a user), so all I could do was restart the tests and wait for them to pass - another 30-60 minutes down the drain.
Maybe this was a problem with my tests, or my system legitimately failed sometimes. The issue was there was no way to tell: every test ran through so much that I couldn’t find what was going on, and had to attribute it to bugs in the workflow of spinning up a browser.
Now What?
I knew I had some problems, and I knew if I wanted stable, reliable, functional applications that I’d have to solve them. I needed automated tests which were:
- Fast
- Predictable
- Informative
- Reliable
Integration tests had failed me, there was only one other option - Unit Tests
My next post will be all about the resources I used to get where I am now, and what I think the solution to the problems above are.
Next Post in Series: Avoiding Integration Tests