Thursday, September 04, 2008

Ruby: Recording Method Calls and Playback With Inject

Sometimes you want to call methods on an object, but you want to delay the actual execution of those methods till a later time.

For example, in expectations you create a mock at parse time, but you actually want the mock to be available at execution time.

class SystemProcess
def start
puts "started"
new StartedProcess
end
end

Expectations do
expect SystemProcess.new.to.receive(:start) do |process|
process.start
end
end

In the above code you define what you expect when the file is parsed, but you actually want the process expectation to be set when the do block is executed. This can be (and is) achieved by using a recorder to record all the method calls on the process object. At execution time the method calls are played back and the initialized process object is yielded to the block.

The code for a recorder is actually quite trivial in Ruby.

class Recorder
attr_reader :subject
def initialize(subject)
@subject = subject
end

def replay
method_stack.inject(subject) { |result, element| result.send element.first, *element.last }
end

def method_stack
@method_stack ||= []
end

def method_missing(sym, *args)
method_stack << [sym, args]
self
end
end

Here's an example usage of a recorder.

class SystemProcess
def start(in_seconds)
puts "starting in #{in_seconds}"
sleep in_seconds
StartedProcess.new
end
end

class StartedProcess
def pause(in_seconds)
puts "pausing in #{in_seconds}"
sleep in_seconds
PausedProcess.new
end
end

class PausedProcess
def stop(in_seconds)
puts "stopping in #{in_seconds}"
sleep in_seconds
self
end
end

Recorder.new(SystemProcess.new).start(1).pause(2).stop(3).replay
# >> starting in 1
# >> pausing in 2
# >> stopping in 3

The only thing worth noting is that by using inject you can use a method chain that returns different objects. Traditional versions of a recorder that I've seen often assume that all the methods should be called on the subject. I prefer the version that allows for object creation within the fluent interface. In practice, that's exactly what was needed for recording and playing back Mocha's expectation setting methods.

10 comments:

  1. This looks a lot like Methodphitamine, except that that allows blocks as arguments, and is designed for converting method stacks to procs:

    http://jicksta.com/posts/the-methodphitamine

    Also, I've written something similar in JavaScript, which is a bit of a pain without method_missing:

    http://jsclass.jcoglan.com/methodchain.html

    It's a really powerful technique as it lets you store pieces of code and replay them later without explicitly writing any functions/blocks.

    ReplyDelete
  2. Isn't this what set_trace_func is used for? What are the benefits of using this instead of set_trace_func?

    ReplyDelete
  3. Do you use this pattern in tests in general?

    I have yet to find a clean way to test that a block gets given to a method as Mocha doesn't have a way of 'expecting' a certain block. I can see that by extending this a little to store [method, args, block], you could record the method calls and then check that a certain method was called with a block that returns the correct results when called.

    Example:

    class Recorder
    def method_stack
    @method_stack ||= []
    end

    def method_missing(sym, *args, &block)
    method_stack << [sym, args, block]
    end
    end

    recorder = Recorder.new
    recorder.foo { 'from the block' }
    assert_equal 'from the block', recorder.method_stack.assoc(:foo)[2].call

    ReplyDelete
  4. Hi Jamie,
    I haven't done anything like that, but it definitely looks interesting.
    Cheers, Jay

    ReplyDelete
  5. Jay, how do you go about testing that a method gets called with a certain block, or a block that outputs a given result?

    I'd love to see a clean solution to that problem. I generally get stuck testing DSL's that instance_eval a supplied block.

    ReplyDelete
  6. Jamie,

    It really depends on the context. I haven't found anything that 'generally' works.

    Most often I end up writing some functional or higher level test.

    Cheers, Jay

    ReplyDelete
  7. Jay,

    This Recorder in unit tests has really struck a nerve. It allows me to test things I could never test before.

    Take the following example:

    # Using a Recorder allows us to test the outcome of the block.
    instance = A.new
    recorder Factory.recorder_instance
    instance.foo(recorder)
    assert_equal 'dsl test', recorder[:method_that_takes_block].block.call

    Implementation here: http://pastie.org/301666

    I'm loving this as I have an especially complex DSL to test. I'm thinking of adding a sister to 'mock' and 'stub' in Mocha for 'recorder' as it is that useful. What are your thoughts?


    Cheers,

    Jamie

    ReplyDelete
  8. I think if you can find a way to make the syntax nice enough for James Mead, it's definitely a good idea.

    You're definitely not the only person who's ever wished they could test the result of a block.

    Cheers, Jay

    ReplyDelete
  9. Simplified + gemified + tests + works with blocks

    https://github.com/grosser/method_call_recorder

    ReplyDelete
  10. Simplified + gemified + tests + works with blocks

    https://github.com/grosser/method_call_recorder

    ReplyDelete

Note: Only a member of this blog may post a comment.