Migrating a legacy gem to use Ruby Refinements
Purely academic refactoring: From monkey patching to proper Ruby 2.1+ idioms
Monkey Patchin’ Artifacts
A legacy client app uses a gem that provides a #normalize
method for strings,
transliterating non-ASCII characters using a static collation table. In many ways,
it does the things ActiveSupport::Inflector
’s #parameterize
and #transliterate
do, only I wasn’t aware of these methods back in 2010. While it does not require
ActiveSupport, it brutally monkey patches Ruby core’s String
class. Meh.
The purpose of the legacy gem is of little relevance – it’s obsolete. I took it for a walk into the modern Ruby days and changed the monkey patching bit to Ruby Refinements. I TDD’ed it and would like to share how you test code providing refinements.
Testing Refinements
Refinements are scoped (the whole purpose of the exercise). Our tests must cover both the added functionality of the Refinement when it is used as well as ensuring the refined class is untainted when the Refinement is not used.
Consider the following Refinement (contrived example):
module Refinery
refine String do
def sugarize
'Honey, honey!'
end
end
end
Clients of that Refinement can use its functionality like this:
using Refinery
'Any String'.sugarize # => "Honey, honey!"
The above example adds the Refinement to the global scope but we can limit it to individual classes and modules, too. Since Ruby test cases are nothing but classes, this is exactly what we’re gonna do.
There was one gotcha: apparently methods added through Refinements aren’t
reflected in repond_to?
. See my workarounds below.
Test::Unit
I created two test classes: one that tests for the inclusion / separation of the Refinement and another that actually tests the implementation.
# refinery_test.rb
require 'test/unit'
class RefineryScopingTest < Test::Unit::TestCase
def test_leaves_string_untainted
assert_raise NoMethodError do
''.sugarize
end
end
end
class RefineryTest < Test::Unit::TestCase
using Refinery # All Strings in this class are refined
def test_sugarize
assert_equal 'Honey, honey!', ''.sugarize
end
end
MiniTest Spec
Minitest’s spec style syntax creates a class per describe
block under the hood.
We can nest the scoping under one outer block.
# refinery_spec.rb
require 'minitest/autorun'
describe Refinery do
describe 'as a refinement' do
using Refinery
it 'adds a sweet method' do
''.sugarize.must_equal 'Honey, honey!'
end
end
it 'leaves String untouched' do
-> { ''.sugarize }.must_raise NoMethodError
end
end
RSpec
As with MiniTest specs, RSpec dynamically creates test classes with its DSL.
describe
and context
blocks all create their own scope.
# refinery_spec.rb
RSpec.describe Refinery do
subject { '' }
context 'refining string' do
using Refinery
it 'adds a sweet method' do
expect(subject.sugarize).to eql 'Honey, honey!'
end
end
it 'leaves String untouched' do
expect { subject.sugarize }.to raise_error NoMethodError
end
end