This post is part of an ongoing series about Unit Testing.
Within our applications, there are often computations that can be expressed as pure functions; that is, methods whose return values are determined only by their input values without observable side effects.
Even if you don’t normally write such functions, it is often useful to think about refactoring your code to create such functions. I find them particularly useful for business rule calculations such as calculating a discount rate based on properties of the customer and the purchase involved. Gary Bernhardt, in his Boundaries talk and related screencast, speaks about organizing applications into a functional core with an imperative shell wrapped around it.
Pure functions are relatively easy to test, but still worth talking about.
Coin Changer Kata
For this post, I’ll use the Coin Change code kata as an illustration. The original description of this kata is no longer online, but the basic idea is that you are given a set of coin denominations and an amount of money, and you need to return the fewest coins that add up to that amount.
Following a by-the-book TDD process, we’d start this kata by writing a very simple test and making it pass. I’ll use Ruby and RSpec for this post with a little Minitest thrown in at the end, but these ideas translate to any language or test framework.
Simplest Possible Test
For the first test, I’m just going test the degenerate case of returning no coins for an amount of 0 cents.
This might seem like a really silly test but in order to write it, I have to make a number of design decisions. Tests are clients of the code they’re testing and are really good at identifying API problems early on. If your object is hard to set up or to use from the tests, there’s a good chance that the “real” clients of the API are going to have the same problem.
I progressively grow this first test as I make each design decision.
- What message should I send in order to make change? I decided to
- Where should I send that message? That is, where should the method that implements the function live? Should it be a loose method, or should I create a class to host it? I decided to create a class.
- What should the class be called?
CoinChanger. Often a class with a name ending in “-er” or “-or” is a design smell, and it might be in this case. But for now, it feels OK. I’ll definitely revisit this decision later.
Note that I’ve changed the outermost describe block to be the class instead of a generic string. This isn’t strictly necessary. In many cases, the string is more descriptive and less coupled to implementation. But there is a very strong RSpec convention to use the name of the class being tested.
I don’t mind violating some conventions when it’s worth it, but in
this case using the class in the outer describe block allows the use
of RSpec features like the
implicitly defined subject
method. David Chelimsky outlines good and bad uses of
I chose to use an explicitly-named subject here because I think it
makes the code clearer;
subject is a really generic name that
doesn’t communicate much information.
I also chose to be explicit when creating the
coin_changer. I used
CoinChanger again rather than using
described_class. This does
introduce a bit of duplication, but it makes the test a bit easier to
read and understand. If I end up creating a bunch of
instances throughout the test file, I might switch over to using
described_class because of the increased duplication.
described_class is also very handy when you start using
- How do I create an instance of that class? Does it need any
constructor parameters? Not yet. Just using
newwill be fine for now.
- What parameters should the
exchangemethod take? Do I pass the amount into the class’
initializemethod, or into the
exchangemethod? I decided that a single
CoinChangercould stick around and make change as many times as I like, so I should pass the amount into the
exchangemethod. This has the happy side effect of making
exchangea pure function, which is much easier to test.
- What should the method return? I decided to return a
Hashfor now. I’m thinking that the keys of the hash will be coin denominations and the values will be the number of coins of that denomination. solution.
- What about the requirement to take a set of coin denominations? I’ll worry about that later; I want my first test to be much simpler. Using TDD plus refactoring allows me to defer some design decisions until I have more information. In Lean Software Development: An Agile Toolkit, Mary and Tom Poppendieck talk about deferring commitment until the last responsible moment: the moment at which failing to make a decision eliminates an important alternative. That’s when we should make our design decisions.
With all of that design out of the way, it’s pretty easy to make the first test pass and it’s time for the next test.
Single Coin, Single Denomination
How about returning a single penny for one cent:
After making this test pass, we need to make a decision about which direction to go next. One option is to figure out how to return multiple coins of the same denomination; the other is to return coins of multiple denominations.
Multiple Coins, Single Denomination
I decided to do the former first and test for two pennies.
At this point, the tests are starting to look very repetitive, and the important parts are obscured by the boiler-plate needed to actually call the code.
One thing we could do about this is extract the amounts and expected results to variables:
This helps a bit, but is still somewhat verbose. It does make the duplication more obvious. As a reader, though, I have to manually compare the duplicated lines to make sure they really are duplicated and that there isn’t some subtle, but important difference somewhere.
A better option is to use a table-based test.
Table-based tests are very useful when testing pure functions, because you can make a simple table of inputs and outputs and remove almost all of the noise and duplication.
Note that I could have written the table and its loop inside of a
it block, but that would make it harder to understand
failures should they happen in the future. This way, I get a single
it block with a good description for each row of the table.
With this table in place, I can easily add more rows to test additional cases until I’m sure my function is doing everything it needs to.
Tests Must Pay Their Own Way
I could add tests for three or four pennies. With the table in place, they’d be really easy to add, so what could it hurt?
That line of thinking is the downside to test tables or any other approach that makes it very easy and clean to add new tests. The more tests you have, the slower they run. Each new test might be a grain of sand but before you know it, you’ve got a beach. Each test must provide enough value to be worth the lifetime cost of maintaining it and running it over and over again.
Tests for more than two pennies are unlikely to teach me anything or to cause my code to change, so they’d just make my tests slower while adding no value. Now that I’m handling multiple coins of a single denomination, it’s time to support multiple denominations.
Multiple Coin Denominations
Once again, I could continue adding tests for new denominations of coins (dimes, quarters, etc.), but those are unlikely to add any additional value. Depending on how I wrote the code to support nickels, though, I might need to add the dimes test to flesh out my design.
Some people would argue that the tests for quarters, 50-cent pieces, and dollar coins are necessary to force those denominations to exist since our list of coins is currently hard-coded. There is some merit to this argument, since there is nothing in the current test suite that will break when those denominations are missing.
However, those tests will really provide almost no value. We’ll write a different test later that will help cover this case.
Multiple Coins, Multiple Denominations
The next most valuable test to write is one where I need to return multiple coins of multiple denominations. Depending on how I wrote the code in response to the previous tests, this may not need additional work. But it’s still an important enough case to be worth testing.
One Last Test
I’ll finish things up with a test for a combination of all of the coins. This is more like a higher-level acceptance or functional test that everything is working together. If a coin denomination is missing, this test will fail. If I’m not handling multiple coins of a denomination, this test will fail.
Now that I’ve driven out the code I need, I’m actually pretty comfortable with just the final “a mix of coins” test; the other tests were useful scaffolding to get me where I needed to be, but the final test checks all of the behavior I care about. Maybe I should delete those other tests.
On the other hand, I’ve got a pretty minimal set of focused tests that check each aspect of the method’s behavior. If any of those fail, I’ll get a targeted failure that tells me what I broke. The last test is more of an acceptance-level test.
Given that I know that I still need to support different sets of coin denominations, I’m going to keep the current tests in place.
At this point, I’m pretty confident in how my code handles American coins. We’ll look at supporting different coin denominations in the next post.
If I were using Minitest instead of RSpec, the final test might look like this:
Note the use of
define_method to dynamically create test methods.
This is a bit tricker than using an RSpec
it block, but not by much.
RSpec is essentially doing the same thing under the hood.
I also modified the descriptions in the table so that I could more easily coerce them into proper method names.
Let the tests drive the design of your code, especially the public APIs of your objects. Testing pain is a good indication of pain that the clients will face.
Be explicit. Spell things out in the tests so that they serve as useful documentation. Be wary of how much of the “magic” of your testing framework you take advantage of.
Be clear. Try to eliminate unnecessary noise from your tests. For functions, table-based tests can be handy.
Make each test pay for itself. Tests cost time and money to run and to maintain. Don’t write tests that don’t add enough value to offset their cost. Or write them until you have the code in place, then delete them.
Defer decisions. Always try to defer design decisions until you are forced to make them. The longer you can wait, the more information you’ll have to make them.