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 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.
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:
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:
Now what does our test look like?
That's a lot better. Instead of relying on standard input and standard output, we rely on the inputstream and outputstream 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.
This can then be tested like this:
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.