"Monkey patching" is used (and misused) a lot in Ruby. It is a way of adding and overriding methods to existing classes without having to rewrite the entire class.
This post looks to demonstrate how monkey patching works in Ruby, as well as some best practices found from across the community.
We will create the project directory demo-monkey-patching-in-ruby and use Bundler to initialize the project:
$ mkdir demo-monkey-patching-in-ruby
$ cd demo-monkey-patching-in-ruby
# Create the required folders and files
# Our recursive creation for the extensions
$ mkdir -p lib/core_extensions/monkey_patching
$ touch lib/monkey_patching.rb lib/core_extensions/monkey_patching/basic.rb
# Make the test folder
$ mkdir spec
$ touch spec/monkey_patching_spec.rb
# Initialise Bundler project
$ bundler init
# RSpec added for testing
$ bundler add rspec --group "development,test"
# Pry added for debugging
$ bundler add pry --group "development,test"
At this stage, our project is now ready to start working with.
Our basic class
Within the lib/monkey_patching.rb file, let's add the following code:
class MonkeyPatching
def hello
'world'
end
def yell
'gaaah!'
end
def extend
'extended'
end
end
This class is contrived for the sake of our demonstration, but in this post we will be aiming for the following:
An example of the hello method being overwritten by a monkey patch.
Demonstrating that yell will remain unaffected after the class is monkey patched.
An example of extending the functionality of the extend method by using monkey patching.
Before we write out our extension file, it is best to do a quick pivot to understand why we will opt to use prepend instead of include for the purpose of monkey patching in Ruby.
include vs prepend
Dave Allie has an excellent post demonstrating the ancestral chain of classes when using include vs prepend.
It is best to read the post through, but as a simple example, here is the relevant ancestral demonstration:
# For `include`
module IncludeExt
end
class IncludeExample
include IncludeExt
end
IncludeExample.ancestors
# => [IncludeExample, IncludeExt, Object, Kernel, BasicObject]
# For `prepend`
module PrependExt
end
class PrependExample
prepend PrependExt
end
IncludeExample.ancestors
# => [PrependExample, PrependExt, Object, Kernel, BasicObject]
The tl;dr from the post most relevant to monkey patching: Use prepend when overwriting/extending an existing class.
If we prepend a module to a class with methods of the same name, we will overwrite the base definition. If we use the super keyword within a prepend, we can call to the original definition and extend its behavior. We will demonstrate both overwriting as well as extending.
Note: there is another alternative where we re-write the class for the monkey patching, but I have opted not to cover the example as it is not an approach I would recommend in the wild. There is an example of this on Geeks for geeks.
The monkey patching spec
Let's write out our spec into spec/monkey_patching.rb so that we can see what our aims our. The approach in these specs are contrived but designed to colocate the examples into one test file:
require 'monkey_patching'
require 'core_extensions/monkey_patching/basic'
RSpec.describe MonkeyPatching do
context 'no monkey patching' do
it 'should return "world"' do
expect(subject.hello).to eq('world')
end
it 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
it 'should return "extended" when we call to extend' do
expect(subject.extend).to eq('extended')
end
end
context 'with monkey patching' do
before do
MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic
end
xit 'should return "people"' do
expect(subject.hello).to eq('people')
end
xit 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
xit 'should return "I am extended" when we call to extend' do
expect(subject.extend).to eq('I am extended')
end
end
end
In our specs, we split up the examples into two contexts:
Without monkey patching.
With monkey patching.
The "With monkey patching" context has a before hook to prepend the module CoreExtensions::MonkeyPatching::Basic that we have included in our file before the tests.
I am also currently using xit to denote the tests that we will skip and work on later.
As things currently stand, if we run bundler exec -- rspec spec/monkey_patching_spec.rb then we should get the following output:
$ bundler exec -- rspec spec/monkey_patching_spec.rb
...***
Pending: (Failures listed here are expected and do not affect your suite's status)
1) MonkeyPatching with monkey patching should return "people"
# Temporarily skipped with xit
# ./spec/monkey_patching_spec.rb:24
2) MonkeyPatching with monkey patching should return our gasp when we yell
# Temporarily skipped with xit
# ./spec/monkey_patching_spec.rb:28
3) MonkeyPatching with monkey patching should return "I am extended" when we call to extend
# Temporarily skipped with xit
# ./spec/monkey_patching_spec.rb:32
Our basic expectations for our standard MonkeyPatching class all pass, and our three tests that are marked xit are not yet run.
If we change our fourth test 'should return "people"' to it and run our tests again, we will see our fourth test fails as subject.hello still returns world.
$ bundler exec -- rspec spec/monkey_patching_spec.rb
...F**
Pending: (Failures listed here are expected and do not affect your suite's status)
1) MonkeyPatching with monkey patching should return our gasp when we yell
# Temporarily skipped with xit
# ./spec/monkey_patching_spec.rb:28
2) MonkeyPatching with monkey patching should return "I am extended" when we call to extend
# Temporarily skipped with xit
# ./spec/monkey_patching_spec.rb:32
Failures:
1) MonkeyPatching with monkey patching should return "people"
Failure/Error: MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic
NameError:
uninitialized constant CoreExtensions
# ./spec/monkey_patching_spec.rb:21:in `block (3 levels) in <top (required)>'
Finished in 0.00441 seconds (files took 0.12602 seconds to load)
6 examples, 1 failure, 2 pending
Failed examples:
rspec ./spec/monkey_patching_spec.rb:24 # MonkeyPatching with monkey patching should return "people"
To remedy this, let's move onto writing some code for our extension to fix this problem.
Writing our extension
The file lib/core_extensions/monkey_patching/basic.rb is where we will write our monkey patching code.
The naming convention used here comes from a Rails convention brought up in this blog post by Justin Weiss where states that the Rails convention is to write our monkey patches to the file-naming convention lib/core_extensions/<class_name>/<group>.rb. Feel free to adjust as you see fit for your own project.
Note: My forced interpretation is different to the Rails convention, but for the sake of this post, I will push forward - the example still relays the important parts. The golden rule is to have a set standard and convention that you follow when it comes to defining where your monkey patches live.
Within the file, add the following:
module CoreExtensions
module MonkeyPatching
module Basic
def hello
'people'
end
def extend
"I am #{super}"
end
end
end
end
Our code here enables us to fulfill all three of our objectives.
hello is overwritten to return 'people'.
extend is extended to return "I am #{super}" where super calls to the super class on our ancestor tree: the original MonkeyPatching implementation of extend.
At this stage, we are ready to go back to our test file and change all of our xit tests to it.
require 'monkey_patching'
require 'core_extensions/monkey_patching/basic'
RSpec.describe MonkeyPatching do
context 'no monkey patching' do
it 'should return "world"' do
expect(subject.hello).to eq('world')
end
it 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
it 'should return "extended" when we call to extend' do
expect(subject.extend).to eq('extended')
end
end
context 'with monkey patching' do
before do
MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic
end
it 'should return "people"' do
expect(subject.hello).to eq('people')
end
it 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
it 'should return "I am extended" when we call to extend' do
expect(subject.extend).to eq('I am extended')
end
end
end
If we now run RSpec, we should see the following output:
$ bundler exec -- rspec spec/monkey_patching_spec.rb
......
Finished in 0.00611 seconds (files took 0.15551 seconds to load)
6 examples, 0 failures
Perfect! Our monkey patching code has been successfully applied to our MonkeyPatching class.
We can look deeper at this using binding.pry to see what is happening within our MonkeyPatching class.
Let's adjust our first test to add binding.pry to our test:
require 'pry' # require pry gem
require 'monkey_patching'
require 'core_extensions/monkey_patching/basic'
RSpec.describe MonkeyPatching do
context 'no monkey patching' do
it 'should return "world"' do
expect(subject.hello).to eq('world')
end
it 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
it 'should return "extended" when we call to extend' do
expect(subject.extend).to eq('extended')
end
end
context 'with monkey patching' do
before do
MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic
end
it 'should return "people"' do
binding.pry # LINE CHANGED HERE
expect(subject.hello).to eq('people')
end
it 'should return our gasp when we yell' do
expect(subject.yell).to eq('gaaah!')
end
it 'should return "I am extended" when we call to extend' do
expect(subject.extend).to eq('I am extended')
end
end
end
Now when we run our tests, our debugger will open up in the terminal. We can use the ancestors method to see what is happening within our MonkeyPatching class.
Here we can note that CoreExtensions::MonkeyPatching::Basic is the first ancestor of MonkeyPatching. This is because we have added our monkey patching code to the top of our class.
The super ancestor of CoreExtensions::MonkeyPatching::Basic is MonkeyPatching, so this is what helps us to access the original implementation of extend.
Below are some more debugging calls to show what is returned by binding.pry to aid in demonstrating what our MonkeyPatching subject has as return values when those methods are invoked:
[2] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.extend
=> "I am extended"
[3] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.hello
=> "people"
[4] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.yell
=> "gaaah!"
Summary
Today's post demonstrated how to use the prepend method to add monkey patching code to a class.
Monkey patching can be useful for augmenting functionality to classes in Ruby, but you must implement them with caution to ensure that you don't break the original functionality or make the implementation of monkey patches confusing and messy.