Here's today's scenario. I'm working on mocking requests to an API to test a Ruby wrapper I've built for it. I have a spec_helper
file with several convenience methods that look something like this:
# spec_helper.rb
# snip
def stub_get(path, options = {})
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(:get, endpoint)
.with(headers: headers, body: options)
end
def stub_post(path, options = {})
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(:post, endpoint)
.with(headers: headers, body: options)
end
def stub_put(path, options = {})
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(:put, endpoint)
.with(headers: headers, body: options)
end
def stub_delete(path, options = {})
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(:delete, endpoint)
.with(headers: headers, body: options)
end
# snip
This looks like something we might be able to DRY up and a great excuse to play around with trying to understand a little Ruby metaprogramming.
First, I tried this using Module#define_method
:
# spec_helper.rb
# snip
HTTP_REQUEST_METHODS = [:get, :post, :put, :delete]
HTTP_REQUEST_METHODS.each do |verb|
define_method("stub_#{verb}") do |path, options = {}|
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(verb, endpoint)
.with(headers: headers, body: options)
end
end
# snip
To my surprise, it just worked. The tests continued to pass. So, I pushed it up to Github curious to see if it would pass on all Ruby versions in my Travis build matrix.
Nope. It failed on Ruby 1.9.3
undefined method `define_method' for main:Object (NoMethodError)
and JRuby 1.9
NoMethodError: undefined method `define_method' for main:Object
with essentially the same NoMethodError
.
Taking a closer look at the documentation on Module#define_method
, I was able to get my build to pass by send
ing define_method
to Object
:
# spec_helper.rb
# snip
HTTP_REQUEST_METHODS = [:get, :post, :put, :delete]
HTTP_REQUEST_METHODS.each do |verb|
Object.send(:define_method, "stub_#{verb}") do |path, options = {}|
endpoint = DEFAULT_API_URL + path
headers = DEFAULT_HEADERS
stub_request(verb, endpoint)
.with(headers: headers, body: options)
end
end
# snip
Questions I still have:
- What changes between Ruby 1.9.3 and Ruby 2.0+ allow the first version to work without complaint?
- Are either of these versions an acceptable use of metaprogramming to reduce code duplication? (My gut is it's better to live with a little duplication over tricky code that might have side effects you don't fully understand. As an exercise, though, I'd really like to understand what's going on here.)
- Are these versions equivalent? The second version seems sketchier than the first. In the first, it looks like
define_method
is makingstub_#{verb}
instance methods ofmain:Object
while the second seems to be making them instance methods of all instances ofObject
. - Would something other than
Object
be a better choice todefine_method
thesestub_#{verb}
helper methods? Which class, if any, would make sense.
If anyone would like to help me on the path towards enlightment, feel free to leave a comment. I'll update with answers as I get a better grasp of this.