Recorder class in Ruby

Apr 23, 2024

Enhancing Ruby Object Interactions with the Recorder Class

In this tutorial, we explore a powerful Ruby class called Recorder. This class wraps any Ruby object to record all method calls made to it. This functionality is invaluable for generating mocks, aiding in introspection, and creating detailed documentation.

Implementing the Recorder Class

The Recorder class operates by overriding the method_missing method to intercept calls to its target object. We’ve also included the respond_to_missing? method to ensure that respond_to? works correctly with dynamically handled methods:

require 'time'

class Recorder
  def initialize(target_object)
    @object = target_object
    @history = []
  end

  def method_missing(method_name, *args, &block)
    if @object.respond_to?(method_name)
      start_time = Time.now
      result = @object.public_send(method_name, *args, &block)
      end_time = Time.now
      @history << { method: method_name, args: args, result: result, called_at: start_time, returned_at: end_time }
      result
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    @object.respond_to?(method_name, include_private) || super
  end

  def _history
    @history.dup
  end
end

Generating Test Mocks with Lambdas

Lambdas are used to create test expectations based on the recorded history, compatible with both RSpec and Mocha:

# Lambda for generating RSpec expectations
rspec = ->entry {
  args = entry[:args].map(&:inspect).join(", ")
  result = entry[:result].inspect
  "expect(object).to receive(:#{entry[:method]}).with(#{args}).and_return(#{result})"
}

# Lambda for generating Mocha expectations
mocha = ->entry {
  args = entry[:args].map(&:inspect).join(", ")
  result = entry[:result].inspect
  "object.expects(:#{entry[:method]}).with(#{args}).returns(#{result})"
}

Displaying Recorded Interactions

Using pretty-printing to provide a clear overview of interactions:

require "pp"
require "stringio"

# Helper for pretty-printing objects
pretty = ->object {
  sio = StringIO.new
  PP.pp(object, sio)
  sio.string
}

# Comment each line of the pretty-printed result
comment = ->string {
  string.lines.map { |line| "# " + line }.join("\n")
}

# Generate detailed method interaction including the result
method = ->entry {
  args = entry[:args].map(&:inspect).join(", ")
  "obj.#{entry[:method]}(#{args})" + "\n" +
    entry[:result].then(&pretty >> comment)
}

Example Usage

Here’s how the Recorder class can be used with a simple string object:

string_proxy = Recorder.new("Hello")
string_proxy.upcase!
puts string_proxy._history.map(&rspec)
# expect(object).to receive(:upcase!).with().and_return("HELLO")
puts string_proxy._history.map(&mocha)
# object.expects(:upcase!).with().returns("HELLO")
puts string_proxy._history.map(&method)
# obj.upcase!()
# # "HELLO"

This setup effectively tracks and analyzes method calls, proving to be an invaluable tool for debugging and documenting the behavior of complex systems.