Logotype of INTECH

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:

  1. Use the instance_variable_get method.
  2. 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.

  1. Use instance_variable_get. The instance_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.

  2. 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 the expensive_method in its count instance variable and exposes a count 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