Why Test Driven Development
26 October 2018
It's difficult to write about Test Driven Development (TDD) without rehashing what others have said but it helps me to organise my thoughts around the matter. So in a way this is a selfish endeavour but I do hope this will at least get readers thinking about TDD and the important role it has in software development.
The promise of software is that it can change. This is why it is called soft ware, it is malleable compared to hardware. A great engineering team should be an amazing asset to a company, writing systems that can evolve with a business to keep delivering value.
So why are we so bad at it? How many projects do you hear about that outright fail? Or become "legacy" and have to be entirely re-written (and the re-writes often fail too!)
How does a software system "fail" anyway? Can't it just be changed until it's correct? That's what we're promised!
In 1974, a long time before I was born, a clever software engineer called Manny Lehman described
The Law of Continuous Change
Any software system used in the real-world must change or become less and less useful in the environment
It feels obvious that a system has to change or it becomes less useful but how often is this ignored?
Many teams are incentivised to deliver a project on a particular date and then moved on to the next project. If the software is "lucky" there is at least some kind of hand-off to another set of individuals to maintain it, but they didn't write it of course.
People often concern themselves with trying to pick a framework which will help them "deliver quickly" but not focusing on the longevity of the system in terms of how it needs to evolve.
Even if you're an incredible software engineer, you will still fall victim to not knowing the future needs of your system. As the business changes some of the brilliant code you wrote is now no longer relevant. Software must change
Lehman was on a roll in the 70s because he gave us another law to chew on.
The law of increasing complexity
As a system evolves, its complexity increases unless work is done to reduce it
What he's saying here is we can't have software teams as blind feature factories, piling more and more features on to software in the hope it will survive in the long run.
We have to keep managing the complexity of the system as the knowledge of our domain changes.
There are many facets of software engineering that keeps software malleable, such as:
- Developer empowerment
- Generally "good" code. Sensible separation of concerns, etc etc
- Communication skills
- Automated tests
- Feedback loops
I am going to focus on refactoring. It's a phrase that gets thrown around a lot "we need to refactor this" - said to a developer on their first day of programming without a second thought.
Where does the phrase come from? How is refactoring just different from writing code?
When learning maths at school you probably learned about factorisation. Here's a very simple example
1/2 + 1/4
To do this you factorise the denominators, turning the expression into
2/4 + 1/4 which you can then turn into
We can take some important lessons from this. When we factorise the expression we have not changed the meaning of the expression. Both of them equal
3/4 but we have made it easier for us to work with; by changing
2/4 it fits into our "domain" easier.
When you refactor your code, you are trying to find ways of making your code easier to understand and "fit" into your current understanding of what the system needs to do. Crucially you should not be changing behaviour.
When refactoring code you must not be changing behaviour
This is very important. If you are changing behaviour at the same time you are doing two things at once. As software engineers we learn to break systems up into different files/packages/functions/etc because we know trying to understand a big blob of stuff is hard.
We don't want to have to be thinking about lots of things at once because that's when we make mistakes. I've witnessed so many refactoring endeavours fail because the developers are biting off more than they can chew.
When I was doing factorisations in maths classes with pen and paper I would have to manually check that I hadn't changed the meaning of the expressions in my head. How do we know we aren't changing behaviour when refactoring when working with code, especially on a system that is non-trivial?
Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and doesn't scale in the long run.
In order to safely refactor you need automated tests because they provide
- Confidence you can reshape code without worrying about changing behaviour
- Documentation for humans as to how the system should behave
- Much faster and more reliable feedback than manual testing
- In order for code to be testable, it generally has to follow best practices of single responsibilities, explicit dependencies (i.e no global variables); properties that also aid in refactoring.
Why Test Driven Development (TDD)
Some people might take Lehman's quotes about how software has to change and overthink elaborate designs, wasting lots of time upfront trying to create the "perfect" extensible system and end up getting it wrong and going nowhere.
This is the bad old days of software where an analyst team would spend 6 months writing a requirements document and an architect team would spend another 6 months coming up with a design and a few years later the whole project fails.
I say bad old days but this still happpens!
Agile teaches us that we need to work iteratively, starting small and evolving the software so that we get fast feedback on the design of our software and how it works with real users; TDD enforces this approach.
TDD addresses the laws that Lehman talks about and other lessons hard learned through history by encouraging a methodology of constantly refactoring and delivering iteratively.
- Write a small test for a small amount of desired behaviour
- Check the test fails with a clear error (red)
- Write the minimal amount of code to make the test pass (green)
As you become proficient, this way of working will become natural and fast.
You'll come to expect this feedback loop to not take very long and feel uneasy if you're in a state where the system isn't "green" because it indicates you may be down a rabbit hole.
You'll always be driving small & useful functionality comfortably backed by the feedback from your tests.
Common objections with pithy responses
Tests don't help me refactor. Every time i refactor loads of tests stop passing/compiling
Remember what refactoring is supposed to be? Just changing the way your program is expressed, not changing behaviour. Now ask yourself why your tests are failing. It will be because your tests are too coupled to implementation details.
You're probably mocking too much and testing irrelevant detail. Remember a unit test is not only on functions/classes/whatever.
A unit of behaviour can be tested and it may have a number of internal collaborators to make that behaviour work; just don't test them!
Listen to your tests and act on what they're telling you.
I don't like writing tests as I want to explore the design first, then I write my tests afterward.
It is hard/time-consuming to write your first test; if your first test is "make a website to rival twitter".
Irrespective of whether you practice TDD or not it is an important skill as a software developer to be able to break problems down into small pieces.
This lets us work in a smaller problem space and deliver small pieces of value quickly, letting us validate our assumptions as we work. This is all about learning from the mistakes of the past with too much work on upfront design.
The beauty of TDD is it forces us to start small - unless you enjoy spending loads of time writing a big test without the endorphin rush of seeing a test pass.
With the constraint of starting small it will challenge your assumptions because you'll get feedback quicker.
Writing tests after the fact is usually harder and more error prone. You are more likely to write code that isn't easy to test because your code has been driven by assumptions in your head rather than tests demanding a specific behaviour.
In addition an important step in TDD is the first one; see how your test fails and see if the error makes sense. This forces you to write ergonomic tests that explain what has gone wrong to the developer reading it.
Too much of my career has been wasted debugging tests that fail with
false was not true
It takes too long
You should read GeePaw's TDD & The Lump of Coding
Fallacy as it explains brilliantly why this line of thinking is wrong (at least once you become proficient with TDD).
If you're too lazy my TL;DR version is
- You don't actually arrive at your desk at 9:30 and constantly write code until 5:30
- What you do is a mixture of. 1) Yes, writing code. 2) Thinking about code, studying existing code. 3) Make a change to the code and run it to see what happens (e.g spin up the server and see what happens, debugging, etc)
- The premise is the tests you write basically are a part of 2 and 3, but make it structured and quicker.
The "studying" part becomes easier because as GeePaw says
it’s almost like the test code forms a kind of Cliff’s Notes for the shipping code. A scaffolding that makes it easier for us to study, and this makes it far easier to tell what’s going on. This will cut our code study time in about half.
All the examples are unrealistic compared to "real" software
This comes back to being able to break problems down. As you gain practice with TDD and software development you'll learn how to break down problems so that they look like the simple examples you learned with.
Generally if your code is too hard to test; it's not "realistic" - it's poorly written.
- The strength of software is that we can change it. Most software will require change over time.
- In order to change software we have to refactor it as it evolves or it will turn into a mess
- A good test suite can help you refactor quicker and in a less stressful manner
- TDD can help and force you to design well factored software iteratively, backed by tests to help future work as it arrives.