I wrote this post for my newsletter, sign up here to get emails like these every week.
Me, from the past:
"Writing tests for my code is pointless. I just tested my code, writing tests for them doesn't add any extra value.
And every time I change the code, I also have to change the tests because they become outdated."
It took me a while to realise that testing code isn't useless, my testing workflow was useless.
Maintaining a good test suite with respectable code coverage is hard. It always feels like more work and is the first thing that gets left out when you're short on time. If you want to keep a healthy test suite, should you write tests before you write the code or immediately after it?
Here's what I do: I don't write tests when I write a new feature. I write them just before I have to change it.
Let me explain,
For each desired change, make the change easy (warning: this may be hard), then make the easy change – Kent Beck
That's my favorite quote on maintaining code. I had to read that a few times before I understood what it meant -
When you write a block of code, you write it with the best understanding and the latest information you have at that time.
Months later, you have to extend this code to add support for another use case. You will have to fit this new logic on top of existing code. It works but isn't always the prettiest result. Your code becomes complex because it wasn't originally written with this new feature in mind. Next week, someone in your team will call this "legacy code".
If you had all this information from the start, you would have written the original code very differently. It would be able to accommodate this feature in a clean way. But, of course, we're not good at predicting the future.
Kent Beck suggests that instead of trying to force fit the new use case on top of the existing code, perform the change in 2 steps:
make the change easy (to perform)
First, refactor the exiting code to make it look like you knew this new feature was coming from the start.
then make the easy change
Next, add code to support the new use case. This part is fairly easy because your code is built for this change.
This approach really resonates with me. You're always refactoring code to keep it fresh.
And you don't have to ask for time or permission to pay tech debt because you're constantly reducing it while you add new features. The code never becomes "legacy".
On the surface, it might look like this will slow you down because you're doing more work. In my experience, it's almost always faster than making the hard change because you don't spend any time trying to understand complex code or fixing regression bugs.
As you can imagine, it would be incredibly handy to have tests in this scenario. But, do I already have them? Probably not.
Here's my workflow:
I start by writing tests for the existing block of code.
Then, I refactor it to make the new change easier to introduce. The tests help make these changes faster without breaking any existing features.
When that's done, I make the easy change of adding the new feature and tests to support it.
Make the change easy, then make the easy change.
When needed, I would create 2 different pull requests - one that introduces tests and shows that they still pass after refactoring and the second which adds the new feature.
Both of these pull requests breeze through the review process because the reviewer is looking at simplified code and has the confidence of tests. Can't the same about a pull request which tries to implement the hard change on top of existing logic.
Note: On a lazy day, I would often just shove the new feature without making an effort. And that's okay, you can't be on your best game everyday. This does, however, comes back to bite me either with long code reviews or bugs that slipped in or simply more refactoring the next time I need to touch this code.
There are 2 things I'd like you to point out in this workflow:
- The best time to write tests for a feature is just before you change it.
If you are working on a codebase that does not have tests, you can start adding them whenever you have to make a change that looks difficult.
The part of your codebase which changes frequently is the one that will benefit the most from tests. Chasing code coverage for the sake of it will not improve your app.
2) Tests are not just for catching bugs.
With the above approach, tests can help you create a to-do list of use cases that can be checked one by one ✅
Once you know what logic needs to be written, the rest of the work is to simply type it.
This workflow helps me get the benefit of tests even when I'm not able to invest a lot of time up front.
I've learned the best methodology is the one that works for you and helps you write better applications.
Hope this was useful on your journey!
Top comments (2)
Thanks for sharing!
I have been bit by trying to force my new functionality into strongly coupled code before which caused me to introduce a non trivial bug when trying to save time (thankfully, it wasn't deployed to Production).
After reverting the commit, I restarted by refactoring the code first to make it easy to introduce my change and then adding my feature.
It turned out to be faster than my first attempt since TDD allowed me to catch potential bugs early and avoid spending most of my time debugging 😅