Test-driven development (aka TDD) is a three-step process. It's often referred to as the "red, green, refactor cycle"
Here's the way it works:
- 🚨 Red: Write a test for the function/module you're going to create before it exists/supports the feature you're adding. This gives you a test that fails (you get a "red" error message).
- ✅ Green: Implement just enough code to get that test passing (you get a "green" success message).
- 🌀 Refactor: Look over the code you have written and refactor it to ensure it's well-written, as easy as possible to read/understand, and well-designed. (The cool thing with this step is that you now have a test in place that will tell you if you break something as you refactor).
- 🔁 Repeat: It's a cycle, after all 😉 Keep going until you've finished implementing everything you need to.
There's a bunch of nuance to this approach and some people can get down-right religious about the whole thing. I try to take a practical approach to TDD and only apply it in situations where I feel it could be actually beneficial.
But that's the big question: "when does TDD make sense?" It's really an intuition you develop and frankly has a lot to do with your comfort and experience with TDD, but here are a few examples of situations where I follow the red-green-refactor cycle of TDD.
Fixing a bug
When I've got a bug to fix, I love reproducing that bug with a test before fixing it. Doing this gives me a huge amount of confidence that I understand the cause of the bug in the first place and when I get the test to green, I know that I've actually fixed the bug and not just tested around the problem.
I'd say that I follow this approach 90% of the time in software I care about (and therefore have tests for). Especially in my open source libraries. Here's an example of such a test.
Fixing a bug? Try TDD.
I don't test all my pure utility functions (I cover most of those with integration tests), but if I've got a utility function of sufficient complexity to need isolated unit tests, then that's another great situation that's well suited for TDD. With these kinds of functions, you often have a pretty well-defined set of inputs and outputs based on the requirements you have for the code.
I think most of us have experienced situations like this (and you will soon if you haven't yet). When I was at PayPal, I needed to format the amount input field as the user typed in the amount of money they wanted to send. The logic for that was surprisingly more complex that you might think thanks to currency precision (some currencies don't have a concept of a decimal amount). Formatting a currency amount like this was a perfect situation for TDD because the possible inputs and required outputs were easy to come up with.
Another good set of examples of this (that's also open source), are the tests for my project rtl-css-js.
Writing a pure utility function? Try TDD.
Well defined user interfaces
It's only since I created Testing Library that I thought TDD of user interfaces was really reasonably possible on the web. This is because:
It's pointless to TDD when you test implementation details.
Honestly, it's pointless to test at all if you're testing implementation details (they're just slowing you down). Part of the point of using TDD is to help you think about the thing you're building from the outside, without thought for the implementation, so when you get to implementing things you don't get lost in the details of the code and can keep the high-level goal in mind. It helps you when you only know what you're building, but not how you're going to build it.
The most popular tools before Testing Library (in all it's varieties), allowed
(and encouraged) you to test implementation details. So to use TDD, that
required that you knew (for example) you were going to create a private method
makeDonation and that it would be called with
(and not the other way around). So TDD always felt like a pointless waste of
time. Just going through the motions.
However, since Testing Library allows you to focus on the user's experience, rather than the implementation, you can follow TDD when building UIs that have a well-defined design and user experience.
Building a well-defined UI? Try TDD.
That's pretty much it for me. I'm sure other people have valid situations where they apply TDD practices and that's fine.
If I'm just doing some exploratory coding (which I do a lot) or messing around then I won't bother following TDD and I'll only add tests when I'm pleased with the direction things have taken. Incidentally, this is the same approach I follow any time I've used a type system (like Flow or TypeScript). This is also the approach I follow with making abstractions.
Writing tests, adding types, and making abstractions for your code are investments in what you've built. Making those investments is pointless if you aren't certain that what you're building is going to be around in the long-term. Those investments can also be ill-advised if you're uncertain what form what you're building will take by the time you're finished. And to take things further, the sunk cost falacy of these investments can influence you in a way that results in a sub-optimal solution.