This is part one of a series:
- What is Test Driven Development
- Test Driven Development: In Practice
At the start my career I had a bit of a love-hate relationship with unit testing. Several times I'd swing from "they are totally indispensable" to "well, I'll do them if I get time".
As I've matured in my career I've been saved more than once by what seemed like a random test failure. Now I couldn't consider a piece of code production level without being fully tested.
Currently I am fortunate enough to lead a team of great devs. And the subject of unit testing via TDD has been a topic of discussion a number of times. So I thought it was probably time for me to write a blog post on the subject.
Test Driven Development
To start with for anyone who's unsure of what TDD is. It is the practice of writing tests before writing any production code. Allowing the tests to shape the design and implementation of said production code. As TDD has developed over the years three rules have been formed to guide developers. They are known as the Three Laws of TDD.
The Three Laws of TDD
- You must write a failing unit test before you can write any production code.
- You must not write more of a test than is sufficient to fail, or fail to compile.
- You must not write more production code than is sufficient to make the currently failing unit test pass.
These laws, when followed correctly, create a cycle of development. This cycle is known as Red-Green-Refactor. Devs practicing TDD will usually find themselves doing a cycle every 30 seconds or so.
The red phase of TDD is where you write your test. An important point to understand is a compile error is a completion of the red phase, as stated in law 2.
Once you have either a compile error or a failing test. Then the red phase ends and you move onto the green phase.
The green phase involves writing just enough to make your test code compile or pass.
If you are just writing enough code to fix a compile error. Then you will now move back to the red phase and continue writing your test. If however you now have a passing test you could move into the third phase, refactor.
This phase of the cycle is not always necessary. But in my experience is crucial and you should find yourself here often.
In this phase you are free to refactor anything to do with your current tests/production code. I would use this as an opportunity to extract duplicated production code out into a private method for example.
But the important point is you must keep all you tests passing. If after a refactor you have a failing test. Then your refactor has broken your logic somewhere and now you must fix it. Taking you right back to the red phase and round and round you go.
My Experience Using The Three Laws
What I've written above is what you will find pretty easily from any Google search on TDD. But I think what made TDD click for me were the experiences of people who had done it for a prolonged period of time. So I wanted to share some my experiences while practicing TDD.
I have found in reality it doesn't make sense to stick to this rule in all circumstances.
It is just not practical to write unit tests when setting up a new application. I want to be clear here, I'm talking about the scaffolding of an application. Doing the basic plumbing. Once this is done I will move into my RGB (Reg-Green-Refactor) cycle.
Another good example is something like a Data Transfer Object. It makes no sense to write tests for a simple class like a DTO.
When I've initially taught TDD to other developers, I follow Law 2 to the letter. But after a certain point I like to share with them what I'm about to share with you.
I will write a whole test even if it has a compile error. Once I have finished I will fix the compile error writing the minimum code possible. If the test is then able to run I will confirm my red phase by running it. Then move to writing the code to make the test pass.
This is only a small bend of the rule in my opinion and saves a bit of flicking between files to stub methods or properties. Obviously with modern tooling it is becoming extremely trivial to stub new methods. But I wanted to mention it.
On the whole I will stick to Law 3. Every now and then I will write a bit of error checking code, a null check for example. Which is technically more code than I could have written to make the test pass.
I think that on the whole this is fair enough and in the sprit of the Law. I feel checking that a value is present is part of the minimum code I can write.
My Approach To Writing Unit Tests
When working with one of the members of my team. He told me of his frustration trying to write his tests. He quickly found it difficult to think of what to test. He struggled with naming of the tests. This quickly lead to frustration and he stopped his TDD.
My advice to him was something I learned from Uncle Bob.
Think of your tests as documentation.
That was my light bulb moment when learning TDD, realising that my tests were a list of things my class would do. Once I thought in that way thinking of tests became much easier.
For example, if I was developing a class which managed outgoings in a monthly budget app. I would probably write tests along the following lines.
As I just mentioned, Uncle Bob recommends thinking of your tests as documentation. Reading the above tests, I think it paints a good picture of what the OutgoingsManager class is doing.
There are many advantages to using TDD but I wanted to pull out a couple that really stand out to me.
There are many things we as developers should be striving for when writing code. But one of the big ones for me is Single Responsibility.
With TDD I find mixed concerns stick out like a sore thumb. Usually because my tests become harder to write. Or my test/production code is getting bulky. This is the sign for me that I need to rethink my design.
The result, the code I write seems simpler and easier to understand. I have more classes but they all do one thing and do it well. Which adds up to a better quality code base and easier development going forward.
To often I have seen code untested in code bases I've worked on. When I've asked why, I've been told that the code is untestable or wasn't important enough to test. While there are rare occasions where code genuinely can't be tested on the whole its either bad code or bad abstractions that are the cause.
An example of this I've bumped into more than once is DateTime.Now. I once saw unit tests that would only pass on certain days of the week due to a direct use of DateTime.Now.DayOfWeek.
If this code had been developed using TDD this most likely would not have happened... Well thats probably a bit over optimistic. But I would hope it would have had less chance of happening.
The reason I say this is that I find TDD makes me think more about abstractions. In order to write my tests I need to use abstractions so I can mock objects. This gives me less chance of creating brittle tests.
A simple wrapper around DateTime.Now would have cured that issue and only takes seconds to do.
While TDD can be a massive benefit it not a silver bullet. As with all things in software development it is just a tool. And there is no point using a hammer when you need a drill.
I have found that trying to practice TDD on complex issues, where I haven't managed to fully understand the problem yet, can horrendous.
In these situations I've found that TDD can be much more of a hindrance. The tests will not cover all requirements, mainly because you might not have worked all out at this point. TDD just ends up slowing me down.
I may go back to TDD once I have gotten things straight in my head. But sometimes this is just not possible.
When working with legacy code TDD can simple just not work. This usually is due to how that legacy code has been written. If it has not been written with testing in mind you can soon find yourself unstuck.
Obviously you could rewrite that code to make it testable but that will all depend on your deadlines or story scope.
Once experienced in TDD I think it can be argued that the time difference is not hugely different for a normal code > test cycle. But it is usually longer to write code using TDD.
Thats where I'm going to end things for this post.
I'm going to follow this up with a more hands on post. Where I will talk you take you through the techniques I've talked about here. I'll also talk about some of the tools I use to make life a bit easier.
I hope you've enjoyed reading this, until next time...