midwire
1/10/2011 - 4:28 AM

A proof of concept for exception testing in Ruby

A proof of concept for exception testing in Ruby

require 'set'

class ExceptionTester
  class TestException < Exception
  end

  # Accepts a block containing the code you want to make exception safety
  # assertions about.
  def initialize(&exercise)
    @exercise = exercise
  end


  # Accepts a block containing a predicate which will prove or disprove the
  # exception safety of the exercised code
  def assert(&invariant)
    recording = record(&@exercise)
    recording.size.times do |n|
      playback(recording, n, &invariant)
      unless invariant.call
        raise "Assertion failed on call #{n}: #{@signature.inspect}"
      end
    end
  end

  private

  # Makes a recording - which is a Set of codepoint tuples - of a given block of code.
  def record(&block)
    recording = Set.new
    recorder = lambda do |event, file, line, id, binding, classname|
      recording.add([event, file, line, id, classname])
    end
    set_trace_func(recorder)
    block.call
    set_trace_func(nil)
    # We only care about method calls
    recording.reject!{|event| !%w[call c-call].include?(event[0])}

    # Get rid of calls outside of the block
    recording.delete_if{|sig|
      sig[0] == "c-call" &&
      sig[1] == __FILE__ &&
      sig[3] == :call    &&
      sig[4] == Proc
    }
    recording.delete_if{|sig|
      sig[0] == "c-call"           &&
      sig[1] == __FILE__           &&
      sig[3] == :set_trace_func    &&
      sig[4] == Kernel
    }

    recording
  end

  # Playback the given recording, and raise TestException once it reaches
  # fail_index
  def playback(recording, fail_index, &invariant)
    recording      = recording.dup
    recording_size = recording.size
    call_count     = 0
    player = lambda do |event, file, line, id, binding, classname|
      signature = [event, file, line, id, classname]
      if recording.member?(signature)
        @signature = signature
        call_count = recording_size - recording.size
        recording.delete(signature)
        if fail_index == call_count
          raise TestException
        end
      end
    end
    set_trace_func(player)
    begin
      @exercise.call
    rescue TestException
      # do nothing
    ensure
      set_trace_func(nil)
    end
  end

end

if __FILE__ == $0
  def swap_keys(hash, x_key, y_key)
    temp = hash[x_key]
    hash[x_key] = hash[y_key]
    hash[y_key] = temp
  end
  h = {:a => 42, :b => 23}

  tester = ExceptionTester.new{ swap_keys(h, :a, :b) }
  tester.assert{
    # Assert the keys are either fully swapped or not swapped at all
    (h == {:a => 42, :b => 23}) ||
    (h == {:a => 23, :b => 42})
  }
end