Web application development agency
Testing that a method is not called in Ruby
Problem definition
Sometimes you need to verify that a method isn’t called in response to a particular action and there’s no way around. Consider the following example:
class YamlFileReport
def html
read_large_file
create_html_and_return_it
end
end
class CachedReport
def initialize(origin)
@origin = origin
@cache = []
end
def html
@cache << @origin.html if @cache.empty?
@cache.first
end
end
The YamlFileReport
class encapsulates some intensive operations that are slow or/and consume a lot of resources. The CachedReport
class, in
turn, caches all the work done by the YamlFileReport
class by memoizing the result of all method calls. In our case html
is the only method being
cached, but it doesn’t have to be so. The reason for having a separate class for caching functionality instead of bulding it into YamlFileReport
is a
topic for another article. For now just take it for granted.
The code examples are made up, but the idea is taken from a real-world application. Method implementations have been omitted for brevity.
The examples assume a Report
interface with a single html
method:
class Report
def html; end
end
While Ruby doesn’t have a formalized notion of interface, it might be useful to keep this in mind for better comprehension. It will also matter later when we will be testing test doubles.
Testing solutions
We need to verify that our CachedReport
class correctly caches the result of a method call by storing it in an instance variable and ensures that
subsequent calls return the cached result immediately without querying YamlFileReport
again.
There are at least two solutions for that:
- Use the
instance_variable_get
method. - Use a Test Spy.
The first approach is rather naive and invasive (and thus not recommended), while the second follows true object-oriented principles and adheres to testing best practices—making it the strongly preferred option. Let’s analyze each in detail.
-
Use
instance_variable_get
. Theinstance_variable_get
method is a well-known method from a metaprogramming toolkit. It allows to circumvent the protection imposed by the Ruby OOP model and access private instance variables of an object directly. A test using this approach might look as follows:class CachedReportTest < Minitest::Test def test_caches_call_to_origin_using_instance_variable_get cached = CachedReport.new(fake_origin) 2.times { cached.html } # Deliberately breaking encapsulation. assert_equal 1, cached.instance_variable_get(:@cache).size end private def fake_origin origin = Object.new def origin.html = "any" origin end end
Breaking encapsulation this way is arguable. The advantage is that the code needed for the task to accomplish is very short and concise. The disadvantage — the main one — is that the test becomes too intimate with the SUT (system under test). For example, if we decide to rename the instance variable
cache
, we will also have to alter the test. This could and should be avoided. The requirements and therefore the test name remain exactly the same, so there is actually no reason for change. I don’t recommend going this route given there are better options we gonna discuss shortly.However, if you absolutely must use this option, including a comment — as shown above — might be a good idea. It serves as a note for future maintainers (possibly yourself) that this was a deliberate choice and the alternatives were considered.
-
Use a Test Spy. A Test Spy is a test double that captures outgoing method calls to a dependent object made by the SUT for later verification. It exposes a retrieval method in order to do that. Here is the implementation:
class CachedReportTest < Minitest::Test def test_caches_call_to_origin_using_spy counter = CacheHitCounter.new cached = CachedReport.new(counter) 2.times { cached.html } assert_equal 1, counter.count end end class CacheHitCounter attr_reader :count def initialize @count = 0 end def html @count = @count.next end end
The
CacheHitCounter
object is a Test Spy. It records calls to theexpensive_method
in itscount
instance variable and exposes acount
method for test verification. I highly recommend using a Test Spy because it verifies behavior rather than implementation, leading to more maintainable tests.
Verifying test double interface
The tests in the examples above have one serious flaw — they don’t verify whether a test double correctly implements the Report
interface.
If the interface changes, tests using such doubles will produce false positives:
class YamlFileReport
# Method has been renamed.
def new_html; end
end
class CachedReportTest < Minitest::Test
# False positive!
def test_caches_call_to_origin_using_spy
counter = CacheHitCounter.new
cached = CachedReport.new(counter)
# .html does not exist anymore!
2.times { cached.html }
assert_equal 1, counter.count
end
end
The YamlFileReport
class no longer implements an html
method, yet the test test_caches_call_to_origin_using_spy
is still passing (incorrectly).
To fix this, we need to ensure all players of the Report
role (YamlFileReport
and CacheHitCounter
in this case) implement the Report
interface:
module ReportInterfaceTest
def test_implements_fixture_set_interface
assert_respond_to @object, :html
end
end
class YamlFileReportTest < Minitest::Test
include ReportInterfaceTest
def setup
@object = YamlFileReport.new
end
end
class CacheHitCounterTest < Minitest::Test
include ReportInterfaceTest
def setup
@object = CacheHitCounter.new
end
end
The ReportInterfaceTest
module now acts as a safety net. If you modify the interface but forget to update all its implementations (including test
doubles), your tests will fail.
The final code listing at GitHub.
The article was inspired by this Stack Overflow question.
tags: ruby, unit testing, oop, patterns, spy, test double