ruby meta-programming tdd

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
comments powered by Disqus