TDD Action Caching in Rails 3
March 28, 2012
[caching
]
[rails
]
[ruby
]
[test-driven-design
]
On my current project, we needed to prove that an action cache was working as expected. Alas, the blogosphere had either out-of-date or unhelpful information. So, after many experiments, we came up with an RSpec test that does what we want. It seems ugly to me, and I hope there's a better way. The names have been changed to protect the guilty. Any resemblances to actual classes and methods are purely coincidental.
We needed to confirm that a certain action was cached. This action is preview
in the brands controller. Using the usual Rails url helpers, we construct some fixture data.
describe BrandsController do
describe "caching" do
let(:brand) { Factory.create(:brand) }
let(:preview_cache_path) {'views/test.host' + preview_brand_path(brand)}
end
end
Then we wrote our first test:
it "should action cache #preview" do
Rails.cache.clear
get :preview, :brand_id => brand.to_param
ActionController::Base.cache_store.exist?(preview_cache_path).should be_true
end
This won't work at all, however; because, in the test environment, caching is turned off.
$ cat config/environments/test.rb
Activator::Application.configure do
...
config.action_controller.perform_caching = false
So, we need an around
block to temporarily turn caching on:
around do |example|
caching, ActionController::Base.perform_caching = ActionController::Base.perform_caching, true
example.run
ActionController::Base.perform_caching = caching
end
That's great, but the default cache store is the :null
store, which, as its name implies, does nothing.
around do |example|
caching, ActionController::Base.perform_caching = ActionController::Base.perform_caching, true
store, ActionController::Base.cache_store = ActionController::Base.cache_store, :memory_store
example.run
ActionController::Base.cache_store = store
ActionController::Base.perform_caching = caching
end
Better. But our tests still won't run because while ActionController uses the cache_store
, Observers
and Sweepers
use Rails.cache
and that is only updated at boot time.
around do |example|
caching, ActionController::Base.perform_caching = ActionController::Base.perform_caching, true
store, ActionController::Base.cache_store = ActionController::Base.cache_store, :memory_store
silence_warnings { Object.const_set "RAILS_CACHE", ActionController::Base.cache_store }
example.run
silence_warnings { Object.const_set "RAILS_CACHE", store }
ActionController::Base.cache_store = store
ActionController::Base.perform_caching = caching
end
Did I mention that Rails.cache is an accessor for the global, constant, RAILS_CACHE
. Ugh.
So, now, we can implement our method
class BrandsController < ApplicationController
caches_action :preview
def preview
end
end
But that is still not enough. caches_action
has an interesting performance enhancement; it doesn't actually set up the action caching unless caching is enabled at class load time. Since we're not turning caching on until test time, the caches_action
method call in the controller class does nothing. We need to re-add it in our test spec.
it "should action cache #preview" do
Rails.cache.clear
BrandsController.caches_action :preview # must be recapitulated to get around load time weirdfullness
get :preview, :brand_id => brand.to_param
ActionController::Base.cache_store.exist?(preview_cache_path).should be_true
end
This is ugly; it doesn't test very much (except the underlying caching module, and why bother testing the framework). At least it proves to ourselves that the action is cached and the cache key is what we expect.
Now that we've got caching under control, let's check cache expiration (using a Sweeper).
it "should clear the cache on #update" do
ActionController::Base.cache_store.write(preview_cache_path, 'CACHED ACTION')
put :update, id: brand.to_param, brand: {one: 'attribute', after: 'another'}
ActionController::Base.cache_store.exist?(sign_up_cache_path).should be_false
end
First, I create a cached object, in this case, just the string 'CACHED ACTION' and then I invoke the action, and then, I hope, the cache will be expired.
It doesn't really matter what happens in the #update
method of the BrandsController
as long as it updates a Brand
object. A sweeper in Rails is a mix of Observer & controller filters, so all you need to do is "declare" it in the controller
class BrandsController < ApplicationController
caches_action :preview
cache_sweeper :brand_sweeper
def update
...
@brand.save
Awesome sauce! Now our tests are red and I'm ready to implement the sweeper
class BrandSweeper < ActionController::Caching::Sweeper
observe Brand # Observers will introspect on the class, but Sweepers don't
def after_update(brand)
expire_action :controller => "brand", :action => :preview, :brand_id => brand.to_param
end
...
And voilĂ ! We have greenness.
So what have we learned from this? The Rails source is still your best friend when exploring a sticky problem. Caching is hard, and testing caching is even harder.