My Take on TDD
At first, I thought about writing an article about what TDD (Test-Driven Development) is, but there's a considerable number of trustworthy sources explaining that already, so I thought about writing up about how I use it, and when I don't actually use it.
I've been using TDD (Test-Driven Development) since 2008, and since then my way of approaching it has changed and evolved in a few ways, even to the point of creating techniques for myself that help me with developing software. I've maintained the basics of TDD intact though, since they work very well for me.
What's TDD?
Test-Driven Development is a practice, a technique, that software developers use to let automated tests guide the production code design through a test-first approach.
It's a cyclic practice, in which each cycle basically works like this:
- You write a test that specifies how a certain part of the system should behave - usually it's a unit of behavior, like a public method that denotes a certain behavior of the System Under Test (SUT) that you wish to have. You run the test and see it fail ("red"), because the implementation for this doesn't exist yet, so the test is pretty much exercising nothing at this point - as it references something that doesn't exist;
- You write the simplest solution (production code) that makes the test pass ("green");
- You refactor the code to improve its quality - by removing duplication, simplifying the structure and so on ("refactor").
These cycles are also known as RGR ("Red/Green/Refactor"). After the "Refactor" phase you go back to "Red" and start writing a new test for a new behavior. This way new behaviors are added to a system through baby steps.
At first, this practice seems rather counter-intuitive, as most software developers learn to build software by going straight to the production code and only then test that what they built works as expected, so it seems like it inverts the order in which work should be done.
However, organizing software development like this brings tremendous benefits, which we'll see next from my perspective.
How TDD has been helping me
Every software developer that adopts the practice correctly realizes how TDD helps keep high focus on code design, since when you write a test-first you necessarily have to define the interface first, which helps avoid leaking implementation details, thus increasing encapsulation. So I'll write about some benefits I've noticed without having first learned about them before I adopted the technique.
Avoid irregular dependencies
Very soon after I adopted TDD I remember that one of the things I noticed was that it helped me identify when a class had dependencies that it shouldn't have. The way it helped me was because, instead of me focusing on the implementation of the solution first, I had to first write the test as a specification for the behavior, which included sometimes defining how an object-under-test communicated with its neighbor objects. In some of these situations I had a certain idea in mind for the implementation before I even wrote the test, but then when writing the test I noticed that my idea involved these extraneous dependencies.
As a result, TDD helps me decrease coupling between code modules.
Better names
It was quite common, before I adopted TDD, for me to change class and method names frequently because I ended up changing the implementation in a way that the code was doing something different from what the names expressed.
With TDD, however, since I "laser-focus" on how I want the system to publicly behave, by specifying the inputs and outputs and what should be done with them. This way, I'm able to think of better names to express these behaviors and their related objects.
Simpler implementation
Before TDD, implementing behavior could turn into a rabbit hole for me; I was implementing some behavior and ended up adding more stuff that I thought about during the implementation, sometimes "just in case". As a result there was usually a lot of dead code introduced just for the sake of "we might need it".
TDD however brings me a good restriction on this: I only implement what my test specifies, nothing else. If I need a new behavior from the same code module, then I do an RGR cycle for this behavior. This helps avoid code implemented "just in case", thus leaving my system only with code that's actually necessary. The result ends up being much simpler production code.
Fearless refactoring
This is probably one of my favorite benefits of TDD: it allows me to fearlessly refactor my production code. Actually, this is not a benefit that comes from TDD itself, but rather from having high coverage of the production code with tests. However, using TDD correctly and consistently leads to high coverage anyway (usually 100% when fully adopted), so the result for me is that I just jump into the production code to do whatever refactoring I want to do with it without fearing that my code might fall apart - because I'll be covered by the tests I had created before even implementing the production code. This gives me a lot of freedom and comfort in making changes to my code.
This has also changed how I deal with legacy untested code; Initially, when I started using TDD, I still approached legacy untested code by changing it directly without testing it properly, and this was frequently a source of bugs caused by me. It was really annoying to have to change legacy spaghetti untested code and ending up making it even worse. What I started doing was to completely cover the unit (usually a public method with a some supporting private methods) with tests, and only then proceeding to refactor the code and then add the behavior I needed to add. This massively improved my ability to deal with legacy systems by not only safely introduce functional changes to them, but also making them more reliable. Sure, not something brought by TDD itself, but it was influenced by the feeling of safety I have when using TDD with well-tested code.
How I personally use TDD
As previously stated, I ended up changing a bit, with time, how I approach TDD.
"Purple" phase
This is actually something I learned from a friend of mine, Rafael Valeira. Before even seeing the test fail for the "right reasons", the code as a whole should at least compile (or not throw some weird error for non-existent code, in the case of interpreted languages). When referencing a class that doesn't even exist yet, in the test, I run the test and see it throw a big screaming error showing that to me, and then I implement the simplest code I can so that it at least compiles (or stops raising the "code piece not found" error), thus moving to the "Red phase". This moves me from code not existing to code existing but not doing the correct behavior, and it further decreases the sizes of the steps I take in TDD cycles.
I don't always use this approach though; Sometimes I just run the tests without the related production code existing, and then I go straight for the implementation of this code, which is the more usual TDD cycle anyway. I leave the "Purple phase" for situations in which the specification I want is still a bit unstable in my mind. Sometimes I even interrupt my cycle at this point and go talk to some stakeholder to clarify things further, which usually helps. It's interesting, because it goes to show that sometimes I might be making too many assumptions and need to clear them up with stakeholders, and TDD helps make this obvious by sticking the behavior specification right on my face before I even started thinking about implementation details.
Not always the simplest idea that works
As the years went by and I got more confidence doing TDD, I ended up cutting corners and jumping straight to more realistic implementation to make my test pass. When following a strict RGR cycle, I really just implement the simplest solution possible, but sometimes I know this solution won't help me much (especially when it's a "fake" implementation just to make the test pass without actually moving me forward towards a certain feature fully developed) so I just cut corners and implement a more realistic code in the Green phase.
For example, if I'm working on an integration test for a UserRepository which should get a user by UUID, instead of implementing a "get_user" method that returns an in-memory ad-hoc created user just to satisfy that test I would perhaps just implement the "real thing" and going to the database to fetch the user for real. This violates the "baby steps" idea of TDD a bit, so I tread carefully with it, but at my current level of experience I feel like it's sometimes more helpful for me.
Refactoring: more or less
Nowadays, I don't feel forced to refactor the production code after I finish the Green phase. It's quite common for me to implement something that satisfies me well, and in these cases I sometimes just don't refactor anything - and don't even look at the code again to try to find something to refactor, like I used to do when I was a "TDD beginner".
Sometimes, however, a very different situation strikes me: the simplest solution I can find to satisfy a test is actually not good, it might duplicate something else and in a very dirty way, sometimes with nonsense logic in the middle. In these cases I end up spending more time refactoring the code, usually with various "passes", until I feel I reached a good structure for the code. Sometimes this ends up making the "Refactor" phase the longest one in the cycle by far, even.
So the "Refactor" phase to me is the one that varies the most, according to the situation I'm at.
Not always behavior
Most of the tests I write exercise behavior, but a few of them exercise state. One usual situation in which I test state is when I want to make sure that an object has a certain dependency to another object to which it needs to delegate some part of the overall work, for example when I'm going to do some dependency replacement.
For example, let's say I want to create an OrderFulfillmentService
with a fulfill_order
method, which orchestrates the order fulfillment, but I want to delegate the payment part to a PaymentClient
. I might want to test that an instance of OrderFulfillmentService
- either instantiated directly or via a Factory Method for example - starts with a PaymentClient
, and later on I could replace this client with a mock so that I could test the service class without exercising the payment client (or, even worse, the payment gateway with which the payment client communicates). Of course, this relates to other topics like contract testing, but they're out of the scope of this article.
When I don't use TDD
Surprise, surprise! I don't always use TDD - even though it's probably the most useful technique I've found to elevate the quality of my code design. But I'm actually rather strict to the usage of the technique: if I'm creating an application from scratch which has to go to production, then I'll surely use TDD. So let's see the other cases.
Proof-of-Concept / experiments
I don't use TDD in PoC and experimental projects. The reason is, when I do these kinds of projects, I'm interested in learning - either business requirements, or technologies, or something else or combinations of these -, and not in producing a high-quality system. I really don't care about design, stability or anything like that at this point.
But there's a critical aspect to these projects when I'm involved and when they're under my leadership: they're throw-away code and should never reach production. These projects have the sole purpose of allowing the involved personnel to learn more, and should not be delivered for the public to use (customers, users etc.).
When I finish all the learning I had to do with such projects, then I start a new one, this time production-ready, from scratch, but using TDD. The things I learned before usually help me write up my tests faster, because I will know for example how certain external dependencies work.
Legacy untested code
Frequently I have to deal with legacy untested code. In this kind of situation I don't have the safety net I need to just start doing TDD with these legacy parts, so what I do is to condition the part for TDD - by covering it with tests, then refactoring it, then usually fixing bugs I find during this process. The result is usually code that's well tested and better factored, in a way that it allows TDD cycles to happen afterward.
"Should I use TDD?"
That's totally up to you, and I think you shouldn't be forced to do it. But it would be wise to take some time to learn it properly, try it for a while, and then make up your mind whether you want to continue using it or not. TDD is one of the best tools I have in my technical tool-belt, perhaps it could be yours too!