In Progress
Unit 1, Lesson 1
In Progress

Sprout Class

We’ve seen how trying to test-drive new functionality in private methods leads to awkward, indirect tests. But how, then do we drive out new behavior in private code? In the conclusion of a three-part series, we discover the final piece of the “testing private methods” puzzle.

Video transcript & code

Recently, we used test-driven design to implement a basic reverse-polish notation calculator.

We later did some refactoring to extract common functionality into a private method.

And then we added a new feature: an operator that generates random numbers in a specified range.

But the test we constructed to drive this feature is a mess. It mocks-out system method and makes some extremely specific assumptions about the feature’s implementation.

This approach to testing felt so awkward and indirect, that we compared it to making a sandwich using a pair of coathangars!

We saw earlier how extracting a private method from already-tested code makes the private code tested by definition. That’s fine and all, but where tests and private methods run into trouble is when we try to add new, tested functionality to those methods. At that point, the tests usually start to get clumsy.

So what’s the alternative? Well, my strategy is that whenever I am tempted to add a significant amount of new code to a private method, I sprout a new class instead.

Let me show you how this might look, by test-driving the same rand operator but with a new approach.

We’ll start with another hand-rolled mock object. But this time, it’s just a lambda that stands in for a “randomizer” object.

We save the passed-in lower and upper bounds for later inspection.

And we return a fake random number: 13.

Then we instantiate our calc instance, this time with a keyword argument injecting our randomizer object.

We create an expression, and tell the calculator to calculate it.

Then we assert that the randomizer received 10 and 20 as its lower and upper bounds, and that the calculated result was 13.

When we run the tests, they complain that there’s no such randomizer initializer argument.

We can add that, with a default value that will remind us we haven’t actually implemented a real randomizer yet.

We save the randomizer to an instance variable.

And later on we use the randomizer as the lookup value for the rand operator.

require "minitest/autorun"

class TestCalc < MiniTest::Test
  def test_add
    calc = Calc.new
    expr = "3 2 +"
    assert_equal 5, calc.calculate(expr)
  end

  def test_subtract
    calc = Calc.new
    expr = "10 7 -"
    assert_equal 3, calc.calculate(expr)
  end

  def test_random
    randomizer = ->(lower_bound, upper_bound) do
      @lower_bound_arg = lower_bound
      @upper_bound_arg = upper_bound
      13
    end
    calc = Calc.new(randomizer: randomizer)
    expr = "10 20 rand"
    result = calc.calculate(expr)
    assert_equal 10, @lower_bound_arg
    assert_equal 20, @upper_bound_arg
    assert_equal 13, result
  end
end

class Calc
  attr_reader :stack

  def initialize(randomizer: ->(*) { fail NotImplementedError })
    @stack = []
    @randomizer = randomizer
  end

  def calculate(expression)
    expression.split.each do |token|
      case token
      when /^\d+$/
        stack.push(Integer(token))
      when "+", "-", "rand"
        right = stack.pop
        left = stack.pop
        op = lookup_binary_operator(token)
        stack.push(op.call(left, right))
      else
        fail "Unexpected token: '#{token}'"
      end
    end
    stack.last
  end

  private

  def lookup_binary_operator(token)
    {
      "+" => ->(left, right) { left + right },
      "-" => ->(left, right) { left - right },
      "rand" => @randomizer,
    }.fetch(token)
  end
end

When we run the tests again, they pass.

So now we’ve specified the public interface for a new role: a randomizer object. It responds to the call message, taking two arguments, and should return a random number.

Let’s test-drive the implementation of a Randomizer.

We’ll start by instantiating an instance of a Randomizer class nested in Calc.

That doesn’t exist, so we create it.

Now, we could fill out the rest of this test the way we did before: by mocking some very specific expectations about how it will use the Kernel.rand method to calculate random numbers in the given range.

require "minitest/autorun"

class TestCalc < MiniTest::Test
  def test_add
    calc = Calc.new
    expr = "3 2 +"
    assert_equal 5, calc.calculate(expr)
  end

  def test_subtract
    calc = Calc.new
    expr = "10 7 -"
    assert_equal 3, calc.calculate(expr)
  end

  def test_random
    randomizer = ->(lower_bound, upper_bound) do
      @lower_bound_arg = lower_bound
      @upper_bound_arg = upper_bound
      13
    end
    calc = Calc.new(randomizer: randomizer)
    expr = "10 20 rand"
    result = calc.calculate(expr)
    assert_equal 10, @lower_bound_arg
    assert_equal 20, @upper_bound_arg
    assert_equal 13, result
  end

  def test_randomizer
    randomizer = Calc::Randomizer.new
    rand_upper_bound_arg = :unset
    randomizer.define_singleton_method :rand do |upper_bound|
      rand_upper_bound_arg = upper_bound
      3
    end
    result = randomizer.call(10, 20)
    assert_equal 13, result
    assert_equal 11, rand_upper_bound_arg
  end
end

class Calc
  class Randomizer
    def call(lower_bound, upper_bound)
      rand(upper_bound - lower_bound + 1) + lower_bound
    end
  end

  attr_reader :stack

  def initialize(randomizer: ->(*) { fail NotImplementedError })
    @stack = []
    @randomizer = randomizer
  end

  def calculate(expression)
    expression.split.each do |token|
      case token
      when /^\d+$/
        stack.push(Integer(token))
      when "+", "-", "rand"
        right = stack.pop
        left = stack.pop
        op = lookup_binary_operator(token)
        stack.push(op.call(left, right))
      else
        fail "Unexpected token: '#{token}'"
      end
    end
    stack.last
  end

  private

  def lookup_binary_operator(token)
    {
      "+" => ->(left, right) { left + right },
      "-" => ->(left, right) { left - right },
      "rand" => @randomizer,
    }.fetch(token)
  end
end

But instead,

let’s test this in a way that focuses on the functional expectations of a randomizer object.

Why don’t we just have it generate 100 random numbers and ensure that they are within the intended range.

This isn’t a comprehensive functional test for random number generation, but it’s a start. And it doesn’t assert anything about how the code generates random numbers.

For instance, we could have the Randomizer#call method construct a range, convert the range to an array, and randomly sample one value from it.

This passes the test!

Now we just need to remember to change the default randomizer to an instance of the Randomizer class.

require "minitest/autorun"

class TestCalc < MiniTest::Test
  def test_add
    calc = Calc.new
    expr = "3 2 +"
    assert_equal 5, calc.calculate(expr)
  end

  def test_subtract
    calc = Calc.new
    expr = "10 7 -"
    assert_equal 3, calc.calculate(expr)
  end

  def test_random
    randomizer = ->(lower_bound, upper_bound) do
      @lower_bound_arg = lower_bound
      @upper_bound_arg = upper_bound
      13
    end
    calc = Calc.new(randomizer: randomizer)
    expr = "10 20 rand"
    result = calc.calculate(expr)
    assert_equal 10, @lower_bound_arg
    assert_equal 20, @upper_bound_arg
    assert_equal 13, result
  end

  def test_randomizer
    rando = Calc::Randomizer.new
    100.times do
      assert_includes (10..20), rando.call(10, 20)
    end
  end
end

class Calc
  class Randomizer
    def call(lower_bound, upper_bound)
      (lower_bound..upper_bound).to_a.sample
    end
  end

  attr_reader :stack

  def initialize(randomizer: Randomizer.new)
    @stack = []
    @randomizer = randomizer
  end

  def calculate(expression)
    expression.split.each do |token|
      case token
      when /^\d+$/
        stack.push(Integer(token))
      when "+", "-", "rand"
        right = stack.pop
        left = stack.pop
        op = lookup_binary_operator(token)
        stack.push(op.call(left, right))
      else
        fail "Unexpected token: '#{token}'"
      end
    end
    stack.last
  end

  private

  def lookup_binary_operator(token)
    {
      "+" => ->(left, right) { left + right },
      "-" => ->(left, right) { left - right },
      "rand" => @randomizer,
    }.fetch(token)
  end
end

So now you’ve seen an illustration of my philosophy around testing private methods. It breaks down to three mutually-reinforcing steps:

  1. Drive new functionality through tests of public methods.
  2. Extract private methods from public methods.
  3. When tests get awkward, sprout a new class, then go back to point #1.

In my experience, if you use test-driven design and apply these guidelines consistently, you’ll never have to struggle with whether to write tests for a private method. Your private methods will always be tested, because they were extracted from tested public methods. And they will never grow complex enough to warrant testing them directly, because instead of adding code to them, you sprout new objects with directly testable public methods.

Happy hacking!

Responses