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:
- Drive new functionality through tests of public methods.
- Extract private methods from public methods.
- 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