Early on as an engineer, my default mode of operation was - to write code and follow it up with unit tests to ensure the code is working as expected. If needed, add additional tests to ensure the highest possible code coverage, and build confidence before pushing code to production. Learning from my mentors, I made the switch from a test-in-the-end mindset to a test-first mindset (popularly known as TDD), which has made a huge difference in my code craftsmanship.
Writing tests first, forced me to think about the behavior desired from the implementation code, switching from tinkering with code to thinking deeply about its behavior. It helped me take a step back instead of getting caught up in the implementation details. While the test-first approach may not be practical in every scenario, using this mindset I could now frame the implementation within the right context even when it was not possible to write unit tests first.
A breaking test for every bug fix
It is often tempting to just fix the bug, but my mentor recommended first writing a breaking test that replicated the bug, then modifying the code to reflect the fix. This approach also gave code reviewers the confidence that the code indeed fixed the bug and the unit tests were proof. Additionally, this acted as a guardrail to catch any accidental bugs introduced especially when rushing to fix the bug.
Finding it hard to write unit tests?
With the test-first approach, when I had a hard time writing tests, it became quite apparent that I had not broken down the modules into small enough chunks and the interactions between modules were probably overly complex. Using the “ease of writing unit tests” as a measure of code complexity proved valuable in improving my code craftsmanship. For existing code, before refactoring, I took the time to ensure I first focused on improving the test coverage. Once I had the confidence that the tests were robust, I could carry out a major refactor without worrying about breaking existing functionality.
Tests as documentation
When jumping into an unfamiliar codebase, well-written tests were a great starting point. It helped me better understand the responsibilities of each module and observing the mocked data in tests revealed the interactions across modules. The framing and language used in these unit tests were equally important as the unit test itself. My personal favorite style is given-when-then - it's concise, sets the context well, and clearly lays out the expectations.
What are some of your unit testing philosophies?
Shoutout to Bef Ayenew for being generous with his time and providing valuable feedback on this article.