Testing the Use of Rails Caching
59/365 - 11/25/09 by vpickering is licensed under CC BY-NC-ND
I was doing some performance work within a Rails application where expensive (computationally) data was being generated on each request. It was quick to implement the low-level caching that wrapped the expensive data generation. In this particular case, I wanted to ensure that the caching logic was valid as there were numerous conditions at play. I normally wouldn’t test caching if the cache key was something trivial (or automatic like using ActiveRecord#cache_key
).
There are only two hard things in Computer Science: cache invalidation and naming things.
– Phil Karlton (source)
This Rails application had the cache_store
set to :memory_store
in the test environment. We wanted to make sure that our tests were still going through the same code paths as would happen in production.
# config/environments/test.rb:
Rails.application.configure do
config.cache_store = :memory_store
end
The inner data structure of ActiveSupport::Cache::MemoryStore
is not publically accessible.
[1] pry(main)> Rails.cache.write('my-cache-key', 'my-value')
=> true
[2] pry(main)> Rails.cache
=> <#ActiveSupport::Cache::MemoryStore entries=1, size=260, options={}>
[3] pry(main)> ls Rails.cache
ActiveSupport::ToJsonWithActiveSupportEncoder#methods: to_json
ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency#methods: require_dependency
ActiveSupport::Cache::Store#methods: delete exist? fetch fetch_multi logger logger= mute options read read_multi silence silence! silence? write write_multi
ActiveSupport::Cache::MemoryStore#methods: cleanup clear decrement delete_matched increment inspect prune pruning? synchronize
instance variables: @cache_size @data @key_access @max_prune_time @max_size @monitor @options @pruning
class variables: @@logger
The raw data is held in the instance variable @data
. We can reach in and grab that to perform some inspections:
[4] pry(main)> Rails.cache.instance_variable_get(:@data)
=> {"my-cache-key"=>#<ActiveSupport::Cache::Entry:0x00007f8ade4da858 @created_at=1580064434.3177679, @expires_in=nil, @value="my-value", @version=nil>}
The caveat of this approach is clear, as we’re reaching into the private space of the Rails cache.
I wanted to make it easier for myself and others to test Rails caching in this project. Creating a test helper is ideal as all the tests can reach for the built utilities when testing code related to caching.
We’ll create a new test helper, that can be included in our test/test_helper.rb
:
# test/support/rails_cache_helper.rb:
module RailsCacheHelper
def with_clean_caching
Rails.cache.clear
yield
ensure
Rails.cache.clear
end
def cache_has_value?(value)
cache_data.values.map(&:value).any?(value)
end
def key_for_cached_value(value)
cache_data.values.each do |key, entry|
return key if entry&.value == value
end
end
private
def cache_data
Rails.cache.instance_variable_get(:@data)
end
end
This helper gives us three utilities:
with_clean_caching
method, which purges the cache before and after the block is executed. It is worth noting that the :memory_store
is only purged at the end of the test suite.cache_has_value?(value)
method, which returns a boolean if the cache has the specified value.key_for_cached_value(value)
method, which returns the key that can be used to look up the specified value.With these new methods, it was much easier to test the caching code. There was no need to do any fancy stubbing and/or using mock objects.
Note: there are more features that can be expanded upon in this helper:
expires_in
).assert_cached(value)
).Now I was able to test the caching specifically. I wrapped my tests in with_clean_caching
as these tests were specific about using caching. I could have also approached this by making it so the cache is purged before each test (e.g., setup()
, before(:each)
), but I didn’t opt for that right now.
The following is a contrived example, but you could imagine that the cache key is generated much deeper in the application code. This makes it difficult to get the cache key to then inspect and make assertions about the cached data.
Using RailsCacheHelper
(that we created above), we can make use of #cache_has_value?
and #key_for_cached_value
to make assertions based on the returned value without needing to know the cache key.
# test/caching_test.rb
class CachingTest < ActiveSupport::TestCase
test 'caching works' do
with_clean_caching do
# bunch of test setup ...
result = Caching.expensive_call(some_object, data_structure, request_data)
assert(cache_has_value?(result))
end
end
test 'caching is not used if request header specifies caching=false' do
with_clean_caching do
# bunch of test setup, and adding caching=false in request_data ...
result = Caching.expensive_call(some_object, data_structure, request_data)
assert(cache_has_value?(result))
end
end
test 'cache varies correctly based on object' do
with_clean_caching do
# bunch of test setup ...
original_result = Caching.expensive_call(some_object, data_structure, request_data)
original_key = key_for_cached_value(original_result)
# setup different_object
changed_object_result = Caching.expensive_call(different_object, data_structure, request_data)
changed_object_key = key_for_cached_value(changed_object_result)
rufute_equal(original_key, changed_object_key)
end
end
test 'cache varies correctly based on data structure' do
with_clean_caching do
# pretty much same as the last test but varying the data structure
end
end
test 'cache varies correctly based on request data' do
with_clean_caching do
# pretty much same as the last test but varying the request_data
end
end
end
Note that this approach worked for me, however, there are many different ways to test Rails caching. For example, this StackOverflow answer suggests making an InspectableMemoryStore
which subclasses ActiveSupport::Cache::MemoryStore
.