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
# snipThis 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
# snipTo 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:Objectwith 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 sending 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
# snipQuestions 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_methodis makingstub_#{verb}instance methods ofmain:Objectwhile the second seems to be making them instance methods of all instances ofObject. - Would something other than
Objectbe a better choice todefine_methodthesestub_#{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.