I teach Test-Driven Development. You may have heard.
And as a teacher of TDD for some quarter of a century now, you can probably imagine that I’ve heard every reason for not doing TDD under the Sun. (And some more reasons under the Moon.)
“It won’t work with our tech stack” is one of the most common, and one of the most easily addressed. I’ve done and seen done TDD on all of the tech stacks, at all levels of abstraction from 4GLs down through assembly language to the hardware design itself. If you can invoke it and get an output, you can automatically test it. And if you can automatically test it, you can write that test first.
(Typically, what they really mean is that the architecture of the framework(s) they’re using doesn’t make unit testing easy. That’s about separation of concerns, though, and usually work-aroundable.)
The second most common reason I hear is perhaps the more puzzling: “But how can I write tests first if I don’t know what the code’s supposed to do?”
The implication here is that developers are writing solution code without a clear idea of what they expect it to do – that they’re retrofitting intent to implementations.
I find that hard to imagine. When I write code, I “hear the tune” in my head, so to speak. The intended meaning is clear to me. When I run it, my understanding might turn out to be wrong. But there is an expectation of what the code will do: I think it’s going to do X.
My best guess is that we all kind of sort of have those inner expectations when we write code. The code has meaning to us, even if we turn out to have understood it wrong when we run it.
So I could perhaps rephrase “How can I write tests first if I don’t know what the code’s supposed to do?” to articulate what might actually be happening:
“How do I express what I want the code to do before I’ve seen that code?”
Take this example of code that calculates the total of items in a shopping basket:
class Basket: def __init__(self, items): self.items = items def total(self): sum = 0.0 for item in self.items: sum += item.price * item.quantity return sum
When I write this code, in my head – often subconsciously – I have expectations about what it’s going to do. I start by declaring a sum of zero, because an empty basket will have a total of zero.
Then, for every item in the basket, I add that item’s price multiplied by it’s quantity to the sum.
So, in my head, there’s an expectation that if the basket had one item with a quantity of one, the total would equal just the price of that item.
If that item had a quantity of two, then the total would be the price multiplied by two.
If there were two items, the total would be the sum of price times quantity of both items.
And so on.
You’ll notice that my thinking isn’t very abstract. I’m thinking more with examples than with symbols.
- No items.
- One item with quantity of one.
- One item with quantity of two.
- Two items.
If you asked me to write unit tests for the total function, these examples might form the basis of them.
A test-driven approach just flips the script. I start by listing examples of what I expect the function to do, and then – one example at a time – I write a failing test, write the simplest code to pass the test, and then refactor if I need to before moving on to the next example.
def test_total_of_empty_basket(self):
items = []
basket = Basket(items)
self.assertEqual(0.0, basket.total())
class Basket: def __init__(self, items): self.items = items def total(self): return 0.0
What I’m doing – and this is part of the art of Test-Driven Development – is externalising the subconscious expectations I would no doubt have as I write the total function’s implementation.
Importantly, I’m not doing it in the abstract – “the total of the basket is the sum of price times quantity for all of its items”.
I’m using concrete examples, like the total of an empty basket, or the total of a single item of quantity one.
“But, Jason, surely it’s six of one and half-a-dozen of the other whether we write the tests first or write the implementation first. Why does it matter?”
The psychology of it’s very interesting. You may have heard life coaches and business gurus tell their audience to visualise their goal – picture themselves in their perfect home, or sipping champagne on their yacht, or making that acceptance speech, or destabilising western democracy. It’s good to have goals.
When people set out with a clear goal, we’re much more likely to achieve it. It’s a self-fulfilling prophecy.
We make outcomes visible and concrete by adding key details – how many bedrooms does your perfect home have? How big is the yacht? Which Oscar did you win? How little regulation will be applied to your business dealings?
What should the total of a basket with no items be? What should the total of a basket with a single item with price 9.99 and quantity 1 be?
def test_total_of_single_item(self):
items = [
Item(9.99, 1),
]
basket = Basket(items)
self.assertEqual(9.99, basket.total())
We precisely describe the “what” – the desired properties of the outcome – and work our way backwards directly to the “how”? What would be the simplest way of achieving that outcome?
class Basket: def __init__(self, items): self.items = items def total(self): if len(self.items) > 0: return self.items[0].price return 0.0
Then we move on to the next outcome – the next example:
def test_total_of_item_with_quantity_of_2(self):
items = [
Item(9.99, 2)
]
basket = Basket(items)
self.assertEqual(19.98, basket.total())
class Basket: def __init__(self, items): self.items = items def total(self): if len(self.items) > 0: item = self.items[0] return item.price * item.quantity return 0.0
And then our final example:
def test_total_of_two_items(self):
items = [
Item(9.99, 1),
Item(5.99, 1)
]
basket = Basket(items)
self.assertEqual(15.98, basket.total())
class Basket: def __init__(self, items): self.items = items def total(self): sum = 0.0 for item in self.items: sum += item.price * item.quantity return sum
If we enforce that items must have a price >= 0.0 and an integer quantity > 0, this code should cover any list of items, including an empty list, with any price and any quantity.
And our unit tests cover every outcome. If I were to break this code so that, say, an empty basket causes an error to be thrown, one of these tests would fail. I’d know straight away that I’d broken it.
This is another self-fulfilling prophecy of starting with the outcome and working directly backwards to the simplest way of achieving it – we end up with the code we need, and only the code we need, and we end up with tests that give us high assurance after every change that those outcomes are still being satisfied.
Which means that if I were to refactor the design of the total function:
def total(self):
return sum(
map(lambda item: item.subtotal(), self.items))
I can do that with high confidence.
If I write the code and then write tests for it, several things tend to happen:
- I may end up with code I didn’t actually need, and miss code I did need
- I may well miss important cases, because unit tests? Such a chore when the work’s already done! I just wanna ship it!
- It’s not safe to refactor the new code without those tests, so I have to leave that until the end, and – well, yeah. Refactoring? Such a chore! etc etc etc.
- The tests I choose – the “what” – are now being driven by my design – the “how”. I’m asking “What test do I need to cover that branch?” and not “What branch do I need to pass that test?”
And finally, there’s the issue of design methodology. Any effective software design methodology is usually usage-driven. We don’t start by asking “What does this feature do?” We start by asking “How will this feature be used?”
What the feature does is a consequence of how it will be used. We don’t build stuff and then start looking for use cases for it. Well, I don’t, anyway.
In a test-driven approach, my tests are the first users of the total function. That’s what my tests are about – user outcomes. I’m thinking about the design from the user’s – the external – perspective and driving the design of my code from the outside in.
I’m not thinking “How am I going to test this total function?” I’m thinking “How will the user know the total cost of the basket?” and my tests reveal the need for a total function. I use it in the test, and that tells me I need it.
“Test-driven”. In case you were wondering what that meant.
When we design code from the user’s perspective, we’re far more likely to end up with useful code. And when we design code with tests playing the role of the user, we’re far more likely to end up with code that works.
One final question: if I find myself asking “What is this function supposed to do?”, is that a cue for me to start writing code in the hope that somebody will find a use for it?
Or is that my cue to go and speak to someone who understands the user’s needs?