Software design is an important aspect of development that often gets overlooked in the rush to start a project or ship new features. Tight deadlines, urgent fixes, or sometimes a lack of investment in design are some of the evils that lead to poor design. I’m guilty of not investing enough time in software design in my early years of coding. However, as I gained more experience and noticed the challenges introduced by poor design, I tried to invest time in design proactively.
While working towards improving design, I noticed some correlation between unit testing and good design. To validate the idea, I researched and found a talk titled “The Deep Synergy Between Testability and Good Design” by Michael Feathers, the author of Working Effectively with Legacy Code. The author suggests that testing pains are the result of bad design and solving those design problems solves testing problems. I couldn’t agree more.
The testability problem became more evident when I tried to write unit tests for legacy code that didn’t have any automated tests. The code base was polluted with global variables and some of the methods were over 100 lines long. The legacy code slapped every design principle in their face. Therefore, our team concluded that the design needs to be fixed before we write any automated tests.
In this article, I'll explore how unit testing, a practice often associated with bug prevention, can drive better software design.
Why should you care about good design?
Some of us might think why should I care about software design? Isn’t getting things done or writing functional code enough? Well, you may be right, if there will never be any changes in the code written. That’s not the case for most software projects, so investing in design would make your life and others easier.
In the book Clean Code, Robert C. Martin suggests that the majority of the cost of software development is in long-term maintenance. A good design helps to minimize the cost of maintenance. Not to mention, how easy it makes the life of the maintainer.
Good software design results in code that is:
Easy to understand (Readable)
Easy to modify (Maintainable)
Easy to test (Testable)
Easy to migrate (to a newer version)
Reusable
Scalable
How does unit testing improve it?
Now, let's explore how unit testing contributes to better software design:
Validates good design
Unit testing can point out flaws in the design (if there are any). In the talk, The Deep Synergy between Testability and Good Design, Micheal Feathers points out that every time we encounter difficulty in testing a module, there’s an underlying design problem. In other words, a good design is testable.
A while back, I was trying to implement a method to convert an image from
png
towebp
format. Initially, when I designed it, the method accepted the path of thepng
image as an input. It then converts the image to thewebp
format and returns the path of the output image.function convertImage(input_file) { ...implementation details ... return output_path; }
It was all good until I wrote a unit test for it. I noticed a couple of implementation issues with the method:
There was no control over where I wanted to store the converted image. The output path was hardcoded in the method (tight coupling).
It was difficult to validate that the image was converted. It is because the filesystem helper used in the method to access the output path was not available in the testing environment (the app was not fully booted in the testing environment).
Unit testing helped me to point out the flaw in the method’s implementation. It got a lot simpler when I updated the method to return an output file instead of a path.
function convertImage(input_file) { ...implementation details ... return output_file; }
Reduces coupling
Coupling refers to the degree of dependencies between modules in software. By modules, I mean classes, methods, components, or any parts of the software. Coupling cannot be completely avoided in software, however high level of coupling (tightly coupled) reflects poor design and is costly to maintain.
Here’s an example of a simple tightly coupled method that calculates annual tax and displays the result:
function calculateTax() { const incomeInput = document.getElementById('income'); const income = parseFloat(incomeInput.value); const taxRate = 0.2; const tax = income * taxRate; document.getElementById('result').textContent = `Your annual tax is $${tax.toFixed(2)}`; }
As you might have noticed, it’s difficult to write unit tests for the above method. The core logic of tax calculation is tightly coupled with UI (DOM manipulation). The method relies on the presence of specific DOM elements which makes it difficult to test the method in isolation. Now, let’s decouple it.
function calculateTax(income, taxRate = 0.2) { return income * taxRate; } function updateTaxResult() { const incomeInput = document.getElementById('income'); const income = parseFloat(incomeInput.value); const tax = calculateTax(income); document.getElementById('result').textContent = `Your annual tax is $${tax.toFixed(2)}`; }
After we separate the core logic from the DOM manipulation, it fixes the design problem and makes the core logic testable. Now, we can write a unit test for the core logic
calculateTax
in isolation. Without unit tests, it is easy to create a poor design (like the first one) and get away with it.Encourages Modularity
In software engineering, modularity is the process of decomposing the software project into distinct independent parts so they can be easily developed, tested, and maintained. Writing unit tests encourages such modular design.
While writing unit tests, if the size of test suites for a module is getting bigger, then it’s an indication that the code is not decomposed into modules well enough. There’s no hard and fast rule for the number of lines in the test suite. However, if it’s difficult to navigate the test suites, then it’s not modular enough.
Facilitates Refactoring
There’s no doubt that refactoring improves software design and reduces complexity accumulated over time. In his book, Refactoring, Martin Flower describes refactoring as a series of small behavior-preserving transformations that improve the design of software. Without enough tests, it’s not reliable to refactor the code base as refactoring changes can accidentally introduce bugs. Hence, tests promote refactoring which in turn promotes good software design.
Therefore, according to Transitive law, unit tests improve software design.
Tests → Refactoring → Software design => Tests → Software design
Promotes Single responsibility principle
Have you ever tried to write a unit test for a method longer than 100 lines? It’s an almost impossible and painful task 😫. It’s also clear that such a long method does many things. Without a unit test, it’s easy to disregard the Single responsibility principle, which states that a method should do only one thing. However, the unit test promotes writing small and focused methods since writing tests for such a monster method is a nightmare. It’s not only for the sake of the unit test, but such a complex method displays flaws in the design. And unit tests make it clear.
Clarifies behavior of module (interfaces or method)
It’s possible to be unaware of all the edge cases when you implement a module. When you write unit tests, you are essentially the first user of the interface. As a first consumer, you can identify any uncovered edge cases in the module early on. You can then refine the behavior to handle such edge cases.
As you work on your next project, consider how you can leverage unit testing as a design tool. The result will be cleaner, more thoughtful code that stands the test of time.
Thank you for reading this article. If you enjoy reading it, feel free to share it with friends!