The Probe Class
Details of Inserting the Probe
Time to put some meat on the probe. The probe is a little unusual,
for Ruby code, in that it doesn't just have class
definitions. Thinking about the probe's environment, it gets loaded
with Ruby's -r
option when RubyExplorer
starts up the target app. So there's going to have to be a little bit
of immediate code in there; code which actually executes at the time
that it's loaded, and wraps require
and the other file-loading functions.
But that's going to present a problem for testing,
in that we don't want that immediate code to screw things up when we
load probe.rb
in our tests; in particular
we do not want require
to get wrapped
during testing. What to do?
Let's step back a minute. Suppose that instead of putting that
immediate code into probe.rb, we move it out to a different file, and
then have RubyExplorer
pass that file
using -r
when we spawn the target app. Then we can
safely load up probe.rb
in our tests. Furthermore,
because the immediate code is separated out, we can safely test it
too; we just have to make sure we use RSpec to stub out any dangerous
methods on the probe first.
OK, let's try this plan out. We'll create a
method, Probe#install
, which will do the dirty
work (whatever that dirty work turns out to be), and call it from a
new file called probe_loader.rb
. The first order of
business is to change our test for RubyExplorer
to
require our probe loader, instead of the probe itself:
it "spawns the target app" do
expected_ruby_command=
"ruby -r #{absolute_src_directory}/probe_loader.rb bin/rails server"
expect(Process).to have_received(:spawn).
with("/bin/bash",
"-l",
"-c",
expected_ruby_command)
end
The change to RubyExplorer
itself is obvious:
require 'pathname'
class RubyExplorer
attr_accessor :target_directory
def run
bin_directory= Pathname.new($0).dirname
src_directory= File.expand_path("#{bin_directory}/../src")
Dir.chdir(target_directory)
Process.spawn("/bin/bash", "-l", "-c", "ruby -r #{src_directory}/probe_loader.rb bin/rails server")
end
end
That taken care of, let's create a test for load_probe.rb:
require "./src/probe"
describe "load_probe.rb" do
let(:the_probe) { Probe.new }
before do
allow(Probe).to receive(:new).and_return(the_probe)
allow(the_probe).to receive(:install)
load("./src/probe_loader.rb")
end
it "calls Probe#install" do
expect(the_probe).to have_received(:install)
end
end
Then we can create probe_loader.rb
and
modify Probe
to make the test pass:
require_relative "probe"
Probe.new.install
class Probe
def install
end
end
Expanding the Probe
The next thing is to have Probe#install
actually
wrap require
, require_relative
,
and load
. We start out by having it
call wrap
for require
:
require "./src/probe"
describe "Probe" do
let(:the_probe) { Probe.new }
describe "#insert" do
before do
allow(the_probe).to receive(:wrap)
the_probe.install
end
it "wraps require" do
expect(the_probe).to have_received(:wrap).with(Kernel, :require)
end
end
end
class Probe
def install
wrap(Kernel, :require)
end
def wrap(klass, method)
end
end
Adding require_relative
and load
:
require "./src/probe"
describe "Probe" do
let(:the_probe) { Probe.new }
describe "#insert" do
before do
allow(the_probe).to receive(:wrap)
the_probe.install
end
it "wraps require" do
expect(the_probe).to have_received(:wrap).with(Kernel, :require)
end
it "wraps require_relative" do
expect(the_probe).to have_received(:wrap).with(Kernel, :require_relative)
end
it "wraps load" do
expect(the_probe).to have_received(:wrap).with(Kernel, :load)
end
end
end
class Probe
def install
wrap(Kernel, :require)
wrap(Kernel, :require_relative)
wrap(Kernel, :load)
end
def wrap(klass, method)
end
end
Now that we have Probe#install
working correctly,
let's actually wrap some code. We face a problem that we didn't in our
spikes: How to test? If we follow our spikes' lead, our wrapper could
just print to $stdout, and then we could check to make sure that we
generate the expected output.
But ultimately, as we noted a while ago, we don't want our wrappers
to interfere with the target's output. So let's try another approach,
and delegate things to a callback. Ruby supports this sort of thing by
allowing any method call to also pass a block of code. So we'll pass a
block of code to wrap
, which will become the body of
the wrapper.
Testing Probe#wrap
then looks like:
describe "#wrap" do
class WrapTarget
def a_method
end
end
let(:wrap_target) { WrapTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapTarget, :a_method) do
@got_called_back= true
end
wrap_target.a_method
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
end
And the code is:
def wrap(klass, method_id)
klass.define_method(method_id) do
yield
end
Making the Wrappers Transparent
Unlike our spike, we never call the original code. This will not do as a permanent state, so let's fix that next. The test:
describe "#wrap" do
class WrapTarget
attr_accessor :a_method_called
def initialize
@a_method_called= false
end
def a_method
@a_method_called= true
end
end
let(:wrap_target) { WrapTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapTarget, :a_method) do |original_method|
@got_called_back= true
original_method.call
end
wrap_target.a_method
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_method_called).to be(true)
end
end
Taking our spike as an example, we try to make it pass:
def wrap(klass, method_id)
original_method= klass.method(method_id)
klass.define_method(method_id) do
yield(original_method)
end
end
Failures:
1) Probe #wrap calls the wrap block back
Failure/Error:
the_probe.wrap(WrapTarget, :a_method) do |original_method|
@got_called_back= true
original_method.call
end
NameError:
undefined method `a_method' for class `#<Class:WrapTarget>'
Did you mean? method
# ./src/probe.rb:9:in `method'
# ./src/probe.rb:9:in `wrap'
# ./spec/unit/probe_spec.rb:43:in `block (3 levels) in <top (required)>'
2) Probe #wrap calls the original method
Failure/Error:
the_probe.wrap(WrapTarget, :a_method) do |original_method|
@got_called_back= true
original_method.call
end
NameError:
undefined method `a_method' for class `#<Class:WrapTarget>'
Did you mean? method
# ./src/probe.rb:9:in `method'
# ./src/probe.rb:9:in `wrap'
# ./spec/unit/probe_spec.rb:43:in `block (3 levels) in <top (required)>'
Finished in 0.03383 seconds (files took 0.12551 seconds to load)
27 examples, 2 failures
Failed examples:
rspec ./spec/unit/probe_spec.rb:51 # Probe #wrap calls the wrap block back
rspec ./spec/unit/probe_spec.rb:55 # Probe #wrap calls the original method
jmax@deepthought $
Failure! What went wrong? Everything worked in our spikes... After
perusing the Ruby documentation for a bit, we realize that we lucked
out a bit in our spikes. They wrap require
, which is a
method in the Kernel
module, whereas our test is trying
to wrap an instance method in an object.
The rules are a little different for wrapping methods in a
class. Instead of using method
to find the original
method, we must use instance_method
. But instance_method
returns an unbound method, which can't be called until it is connected (bound, in Ruby's terminology) to an object. We do this
using bind
. In essence, the unbound method has
no self
, and what we do with bind
is provide a value for self
.
The final code ends up looking like:
def wrap(klass, method_id)
original_method= klass.instance_method(method_id)
klass.define_method(method_id) do
yield(original_method.bind(self))
end
end
And we're back to passing tests.
We Need More Wrappers
This little speed bump suggests that we've got a couple more cases of wrapping a method that we need to consider, though: wrapping a class method, and wrapping a module method.
Turning to wrapping class methods, we'll
create Probe#wrap_class_method
, and add a test for it:
describe "#wrap_class_method" do
class WrapTarget
@@a_class_method_called= false
def self.a_method
@@a_class_method_called= true
end
end
before do
@got_called_back= false
the_probe.wrap_class_method(WrapTarget, :a_method) do |original_method|
@got_called_back= true
original_method.call
end
WrapTarget.a_method
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(WrapTarget.class_variable_get(:@@a_class_method_called)).to be(true)
end
end
With the implementation:
def wrap_class_method(klass, method_id)
original_method= klass.method(method_id)
klass.singleton_class.define_method(method_id) do
yield(original_method)
end
end
On to wrapping a module metod. The Ruby documentation, as well
as our original spikes, suggest that our existing wrap
method should hand wrapping a module method without change. Let's add
another test, where we try to wrap a module method, and see.
describe "#wrap on a module method" do
module WrapModule
def a_module_method
@a_module_method_called= true
end
end
class WrapModuleMethodTarget
include WrapModule
attr_accessor :a_module_method_called
def initialize
@a_module_method_called= false
end
end
let(:wrap_target) { WrapModuleMethodTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapModule, :a_module_method) do |original_method|
@got_called_back= true
original_method.call
end
wrap_target.a_module_method
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_module_method_called).to be(true)
end
end
And it works.
The Wrappers and Arguments
The final stage before we're ready to try this all out on our target Rails app is to handle the arguments of the wrapped function. Right now, we aren't passing any arguments when we call the original method, and that's pretty obviously not going to work. Let's add tests to each of our method wrapping cases (instance, class, and module methods), to force us to pass the method arguments through. We have to tweak the test setup a bit to do this, but only a little. The test code ends up looking like:
describe "#wrap" do
class WrapTarget
attr_accessor :a_method_called, :arg_value
def initialize
@a_method_called= false
@arg_value= nil
end
def a_method(arg)
@a_method_called= true
@arg_value= arg
end
end
let(:wrap_target) { WrapTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapTarget, :a_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
wrap_target.a_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_method_called).to be(true)
end
it "passes the arg value through" do
expect(wrap_target.arg_value).to eq("expected arg value")
end
end
describe "#wrap_class_method" do
class WrapClassMethodTarget
@@a_class_method_called= false
@@arg_value= nil
def self.a_method(arg)
@@a_class_method_called= true
@@arg_value= arg
end
end
before do
@got_called_back= false
the_probe.wrap_class_method(WrapClassMethodTarget, :a_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
WrapClassMethodTarget.a_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(WrapClassMethodTarget.class_variable_get(:@@a_class_method_called)).to be(true)
end
it "passes the arg value through" do
expect(WrapClassMethodTarget.class_variable_get(:@@arg_value)).to eq("expected arg value")
end
end
describe "#wrap on a module method" do
module WrapModule
def a_module_method(arg)
@a_module_method_called= true
@arg_value= arg
end
end
class WrapModuleMethodTarget
include WrapModule
attr_accessor :a_module_method_called, :arg_value
def initialize
@a_module_method_called= false
@arg_value= nil
end
end
let(:wrap_target) { WrapModuleMethodTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapModule, :a_module_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
wrap_target.a_module_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_module_method_called).to be(true)
end
it "passes the arg value through" do
expect(wrap_target.arg_value).to eq("expected arg value")
end
end
And modifying the code to actually pass arguments through to the wrapper, we have:
class Probe
def install
wrap(Kernel, :require)
wrap(Kernel, :require_relative)
wrap(Kernel, :load)
end
def wrap(klass, method_id)
original_method= klass.instance_method(method_id)
klass.define_method(method_id) do |*args|
yield(original_method.bind(self), args)
end
end
def wrap_class_method(klass, method_id)
original_method= klass.method(method_id)
klass.singleton_class.define_method(method_id) do |*args|
yield(original_method, args)
end
end
end
Looking at our wrappers, we notice that we've ommited something
very important from our tests: return values. Right now, we're
completely ignoring the return value from the method we've
wrapped. This hasn't bitten us, because Ruby's convention of returning
the final value computed in a method means that we are, in fact,
returning the correct values. (And honestly, nobody ever pays
attention to the return values
from require
, require_relative
,
and load
anyway). Still it's an embarrassing omission, so
let's get it fixed before anyone notices.
Adding tests for each of our three scenarios, we have:
describe "#wrap" do
class WrapTarget
attr_accessor :a_method_called, :arg_value
def initialize
@a_method_called= false
@arg_value= nil
end
def a_method(arg)
@a_method_called= true
@arg_value= arg
return "a_method expected return value"
end
end
let(:wrap_target) { WrapTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapTarget, :a_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
wrap_target.a_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_method_called).to be(true)
end
it "passes the arg value through" do
expect(wrap_target.arg_value).to eq("expected arg value")
end
it "returns the original return value" do
expect(wrap_target.a_method("")).to eq("a_method expected return value")
end
end
describe "#wrap_class_method" do
class WrapClassMethodTarget
@@a_class_method_called= false
@@arg_value= nil
def self.a_class_method(arg)
@@a_class_method_called= true
@@arg_value= arg
return "a_class_method expected return value"
end
end
before do
@got_called_back= false
the_probe.wrap_class_method(WrapClassMethodTarget, :a_class_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
WrapClassMethodTarget.a_class_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(WrapClassMethodTarget.class_variable_get(:@@a_class_method_called)).to be(true)
end
it "passes the arg value through" do
expect(WrapClassMethodTarget.class_variable_get(:@@arg_value)).to eq("expected arg value")
end
it "returns the original return value" do
expect(WrapClassMethodTarget.a_class_method("")).to eq("a_class_method expected return value")
end
end
describe "#wrap on a module method" do
module WrapModule
def a_module_method(arg)
@a_module_method_called= true
@arg_value= arg
return "a_module_method expected return value"
end
end
class WrapModuleMethodTarget
include WrapModule
attr_accessor :a_module_method_called, :arg_value
def initialize
@a_module_method_called= false
@arg_value= nil
end
end
let(:wrap_target) { WrapModuleMethodTarget.new }
before do
@got_called_back= false
the_probe.wrap(WrapModule, :a_module_method) do |original_method, args|
@got_called_back= true
original_method.call(*args)
end
wrap_target.a_module_method("expected arg value")
end
it "calls the wrap block back" do
expect(@got_called_back).to be(true)
end
it "calls the original method" do
expect(wrap_target.a_module_method_called).to be(true)
end
it "passes the arg value through" do
expect(wrap_target.arg_value).to eq("expected arg value")
end
it "returns the original return value" do
expect(wrap_target.a_module_method("")).to eq("a_module_method expected return value")
end
end
On to the next thing: Let's try it out on our target app.