Testing Code That's hard to test

So if you're like me, you're 5'10" and sport a ruggedly awesome beard. And if you're really like me, you swallowed the T/BDD pill years ago and have struggled to test on 15 to 20 projects trying to really feel like you're experiencing those advantages. I'm not going to write a "why you should be using TDD" blog, that's been discussed in the Ruby and Agile communities ad nauseum. Instead, I'm going to show you one of the patterns I've discovered during my apprenticeship at 8th Light that has allowed me to to stop saying "I tried to test it" and start saying "I did test it".

Prerequisite

First, of all, we need to talk about dependencies. If you're familiar with the SOLID principles of Object Oriented design, then you're doing good. Specifically we're going to talk about:

  • Dependency Inversion Principle: Your high-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
  • Single Responsibility Principle: Every object should have a single responsibility, and that responsibility should be entirely encapsulated by the class.

We're going to use the Dependency Injection pattern to help with this. The basic idea is that instead of referencing dependencies directly (creating instances ourselves or specifying which namespace to find methods in), we'll reference objects that abstract those dependencies. This will allow us to test the code in question apart from the details they depend on. Then we're going to extract responsibilities into classes so they're easier to test and reason about.

Code That's Hard to Test

Let me show you some code that's hard to test. It is a low level class that prompts the user for an integer and returns it. If the user doesn't enter a valid input, it tells the user and reprompts.

1 class UserPrompter
2   def self.integer
3     print "Enter an integer: "
4     input = gets
5     return input.to_i if input =~ /^\\d+$/
6     puts "That is not an integer"
7     self.integer
8   end
9 end

Now how do we go about testing this code? Not too long ago, my brain would have first jumped to to popen3 or something similar. That's a long miserable tangent that leads you to hate testing -- if you can even get it to work at all (which I have).

As I learned more, it got better, I'd mock $stdin and $stdout like this:

 1 class UserPrompterTest < Test::Unit::TestCase
 2 
 3   require 'stringio'
 4   def mock_stdin(user_input)
 5     stdin = $stdin
 6     $stdin = mock = StringIO.new user_input
 7     yield
 8     mock.read
 9   ensure
10     $stdin = stdin
11   end
12 
13   def mock_stdout
14     stdout = $stdout
15     $stdout = mock = StringIO.new
16     yield
17     mock.rewind
18     mock.read
19   ensure
20     $stdout = stdout
21   end
22 
23   def test_integer_happy_path
24     stdout = mock_stdout do
25       stdin = mock_stdin "12" do
26         assert_equal 12, UserPrompter.integer
27       end
28       assert_equal '', stdin
29     end
30     assert_equal 'Enter an integer: ', stdout
31   end
32   
33   # ... more tests for unhappy path ...
34 end

But wow, that's pretty miserable, too! We can't test that it works without mirroring the messages (to be fair, we can make it a bit better by matching keywords, but its still problematic). We're hijacking global variables, so our tests can't run concurrently! What if our test suite prints to $stdout? That could mess our test up (plus the output would be really confusing). What if we change our messages? We'll have to go change our tests. And what if we ever want it to talk to something other than standard ouput? We might want it to read its input from a file, or over a stream from some remote terminal.

People say when your code is hard to test, it's a design smell. This is what they mean. This code is hard to test because it's coupled to its dependencies. It's fragile, it's hard to read, and it just generally reeks of hackiness.

Inject the Dependencies

So what is the problem here? Why is this code so hard to test? It's pretty simple code, but we just can't get in there because we have hard dependencies on standard input and standard output. The method invokes Kernel#print directly (Kernel is mixed into Object, so all objects inherit it), forcing us to intercept $stdout as shown above.

So what's the solution? Remove the dependencies on stdin and stdout. Instead, we'll inject those dependencies like this:

1 class UserPrompter
2   def self.integer(input_stream, output_stream)
3     output_stream.print "Enter an integer: "
4     input = input_stream.gets
5     return input.to_i if input =~ /^\\d+$/
6     output_stream.puts "That is not an integer"
7     self.integer input_stream, output_stream
8   end
9 end

Now what does our test look like?

 1 class UserPrompterTest < Test::Unit::TestCase
 2 
 3   require 'stringio'
 4 
 5   def test_integer_happy_path
 6     stdin = MockStream.new('12')
 7     stdout = MockStream.new
 8     assert_equal 12, UserPrompter.integer(stdin, stdout)
 9     assert_equal '', stdin.read
10     assert_equal 'Enter an integer: ', stdout.string
11   end
12   
13   # ... more tests for unhappy path ...
14 end

That's a lot better. Instead of relying on standard input and standard output, we rely on the input_stream and output_stream parameters. We no longer change global state. Any code that wants to use this can decide what it wants to read from and what it wants to write to (e.g. UserPrompter.integer($stdin, $stdout) would result in the initial behaviour).

The Single Responsibility Principle

Personally, I'd take it a step further, and abstract away from this code even more. This would allow me to get away from the details of the message (dependency inversion) and just test the logic itself. We do this by delegating the responsibilities of reading from the input stream and writing to the output stream to other objects.

 1 class UserPrompter
 2   def self.integer(reader=Reader, writer=Writer)
 3     writer.ask_for_int
 4     int = reader.read_int
 5     return int if int
 6     writer.notify_not_an_int
 7     self.integer reader, writer
 8   end
 9 
10   class Writer
11     def self.ask_for_int(output_stream=$stdout)
12       output_stream.print 'Enter an integer: '
13     end
14 
15     def self.notify_not_an_int(output_stream=$stdout)
16       output_stream.puts 'That is not an integer'
17     end
18   end
19 
20   class Reader
21     def self.read_int(input_stream=$stdin)
22       input = input_stream.gets
23       input[/^\\d+$/] && input.to_i
24     end
25   end
26 end

This can then be tested like this:

 1 class UserPrompterTest < Test::Unit::TestCase
 2   def setup
 3     @reader = MockReader.new
 4     @writer = MockWriter.new
 5   end
 6 
 7   def test_integer_happy_path
 8     @reader.int_responses = [12]
 9     assert_equal 12, UserPrompter.integer(@reader, @writer)
10     assert_equal 1, @reader.times_read
11     assert_equal 1, @writer.times_asked_for_int
12     assert_equal 0, @writer.times_notified_not_an_int
13   end
14 
15   # ... test non-happy path ...
16 end
17 
18 # ... declare mocks here ...

This is robust because we know it does what it says it does, as we no longer have to parse strings, but can instead check that it invoked the methods that are responsible for printing the strings. What's more, the integer prompter knows nothing about strings or streams! It could prompt from anything, even another method (treat a service as the user and wrap its API with a custom reader and dummy writer). All the details of what it means to ask for the integer, and what it means to get the integer are now encapsulated by objects responsible for those tasks.

Because we've separated these responsibilities out, we can then test the reader and writer in isolation. There used to be several reasons our tests could break. They could break because we needed to change the logic of prompting for an integer (client thinks error message is redundant, just reprompt), or because the message itself changed (client wants the error message to explain that an integer contains only digits) or the process of prompting changed (need to check that the stream is open before writing). Now these changes are encapsulated in their own objects. We could perhaps take this even further by pulling the message contents out and placing them in a settings file.

Conclusion

Code should be easy to test. If it isn't, figure out why. If you're having difficulty intercepting a dependency, pass it in instead. If the code is doing too much, extract the lower responsibility into its own method or object.

Another nice advantage of this is that if you break something while refactoring, probably only one method will fail, the method that checks the thing that broke. When your tests let the objects use their dependencies, then they will fail when the dependent code fails, making for a lot of spam in your test results and reducing your ability to quickly identify the regression.

Here is the full version of the final code.

Josh Cheek, Software Craftsman

Josh Cheek Veteran and Vegetarian, passionate about education, author of Ruby Kickstart