Skip to content

Instantly share code, notes, and snippets.

@O-I
Last active August 29, 2015 14:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save O-I/15c54540269ede3393a2 to your computer and use it in GitHub Desktop.
Save O-I/15c54540269ede3393a2 to your computer and use it in GitHub Desktop.
[TIx 1] Module#define_method behavior across Ruby versions

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 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

# snip

Questions I still have:

  1. What changes between Ruby 1.9.3 and Ruby 2.0+ allow the first version to work without complaint?
  2. 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.)
  3. Are these versions equivalent? The second version seems sketchier than the first. In the first, it looks like define_method is making stub_#{verb} instance methods of main:Object while the second seems to be making them instance methods of all instances of Object.
  4. Would something other than Object be a better choice to define_method these stub_#{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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment