I fixed a really tricky bug this afternoon. I took a roundabout path towards the bug’s cause, and used a couple of tools from my ruby toolbox.
So ruby is dynamically typed, for better or worse. I used to think that was a great idea - who cares what something is, as long as you know what it does, right? Ducks and all that?
Not always the case. I’m starting to think ruby is too permissive. Not only can you not be sure what type an object is until runtime, you can’t be sure what the types themselves consist of - and they can change from moment to moment.
So here’s the problem:
Scenario: Uploading after logging in Given I am logged in as "email@example.com" undefined method `context=' for #<Ambition::Adapters::ActiveRecord::Select:0x3523494>
The login step triggers a GET via webrat, and the call to ambition is far below that. About 25 method calls below:
But the request works fine when hit from the browser—so maybe something’s undefining
#context in the test environment, or similar. In ambition /
unless instance.respond_to? :context klass.class_eval do attr_accessor :context, :negated def owner; @context.owner end def clauses; @context.clauses end def stash; @context.stash end def negated?; @negated end end end instance.context = context # line 122
Turns out, it’s the reverse. If we already have
attr_accessor (which defines it, along with
#context=) is never called. But where the hell is
#context coming from? This test involves rails, hammock, ambition, rspec, cucumber, webrat, machinist, and faker. Oh dear.
So first, OK. Let’s just have a look at what that object can do. Just above that
# What methods does this object have, that aren't common to all objects? p instance.methods.sort - Object.methods
Which gives us
["both", "call", "chained_call", "dbadapter_name", "downcase", "either", "not_equal", "not_regexp", "quote", "quote_column_name", "quote_string", "quote_table_name", "quoted_date", "quoted_false", "quoted_string_prefix", "quoted_true", "sanitize", "statement", "upcase"]
Which doesn’t include
respond_to? :context must have returned true, otherwise all those methods would have been
class_evaled. We’ll need a more subtle trick.
instance.method(:method_missing).owner #=> Kernel
Damn, they must have known we’d check that first. If it’s owned by
Kernel, then no parent or mixin’s
#method_missing can be responding to the
#context call. How about..
instance.method(:context).owner #=> Spec::DSL::Main
instance responds to
Spec::DSL::Main#context. It must have been mixed in by rspec. Let’s have a look in its source:
def describe(*args, &block) [ ... ] end alias :context :describe
Yep, every class responds to
#describe) when rspec is in the mix. But why doesn’t it appear in the
I had to think about this for a bit. It’s because every class responds to
#context when rspec is in the mix, including
Object itself. When we subtracted globally shared methods from the list (
#context was one of them. Sure enough,
instance.methods.include? 'context' #=> true
The fix is easy - the naming collision can be easily avoided in ambition by checking for
unless instance.respond_to? :context= [ ... ] end
But there’s a good lesson here. Two completely unrelated bits of metaprogramming, each innocuous on their own, colliding and causing mayhem. It might seem like an elegant way to write code, and in some situations it is—but there’s got to be a better way. I’m hoping it’s this.