NDepend Blog

Improve your .NET code quality with NDepend

10 Reasons Why You Should Write Tests

February 9, 2021 8 minutes read

10 Reasons Why You Should Write Tests

As many, I started programming when I was a child 3 decades years ago. With no doubt, the most important practice I’ve adopted during my professional career is to write automatic tests. Not only there are plenty of short/mid/long terms benefits but it just leads to better code and more pleasant working days.

I’d like here to enumerate reasons why you should write tests, ordered by when you will enjoy the benefits: from immediate to long term.

1. Spot ambiguities early

No matter if you write tests before writing code, or if you write both code and tests at the same time, the practice leads you to anticipate edge case. What happens if this reference is null? If this data is not yet initialized? How the code could be broken? … The key is: when you write test you take the perspective of the one that will consume your code. It forces you to have an holistic approach of the behavior to implement. This way ambiguities you get from requirements become obvious and are immediately taken account when code is written the first time.

2. Write better code in the first place

Typically developers that don’t write tests see this practice as time-loss: indeed why writing the code itself and then write more code to double-check the logic just written. A quick manual test is enough isn’t it? No, it isn’t. When you write tests you realize how difficult it is to get the code right from the first shot. You get to learn to not trust your own code and question yourself. Our brain is powerful but subject to cognitive bias limitations. Double-checking the code just implemented by writing more code to exercise it necessarily leads to catch many mistakes.

3. Better design

Students in software engineering have a lot of fun imagining and implementing pure algorithms that produce outputs from inputs. In the real world, most code written depends on complex infrastructures like UI, databases, networks… An essential practice promoted by Object-Oriented-Programing (OOP) is to abstract away complex infrastructure implementations behind interfaces. But one cannot abstract everything. One key difficulty is to identify early what should be abstracted and what should not. By writing tests, the code gets exercised in a lightweight sandbox context. Thus tested code must be decoupled from complex infrastructures. This way the need for the the right abstractions naturally emerges. Writing code and tests at the same time leads to fully testable code. In the end fully testable code becomes a definition of well-designed code.

4. Easier debugging

Debugging can be a tedious activity. It usually takes time to start an entire application and manually reach the right context that will invoke the code to debug. Debugging implies a lot of experiments: as a consequence this expensive process must be repeated again and again until the code thoroughly works.

As explained in the previous section, writing tests forces the developer to abstract the code away from the underlying complex infrastructure. This way it becomes easy to write a test to reach the code you want to debug in a certain context and a lots of time gets saved.

5. Up to Date Code Documentation

Writing and maintaining documentation is seen as a burden by developers. After all the source code is the design. If one wants to reverse-engineer how it works one should read the source code. But reading code is difficult and leads to a lot of WTFs: Why is it implemented this way? What should be the output given the input? In which order should those methods be called? What was the original intention? Thus documentation is needed but it is a burden, especially to keep it in-sync with code modifications. In this context, tests themselves constitute a great documentation.

  • Because tests are written at development time they capture the initial intention that is often somewhat blurred by implementation details.
  • Tests must necessarily be kept up-to-date with code modifications. Else they cannot pass.
  • A test is a small program that exercise step-by-step a scenario.

For example our public API is fully documented. However when a user has a specific question, it often makes sense to redirect her to the right tests.

6. Measure Progress

One key measure to track is how much code is actually exercised by your automatic test suite. This is called code coverage. The more, the better. When 100% of the code just written is covered by tests it means a lot of things:

  • Your code is fully testable and hence, it is well designed. When the code will be modified, the changes will be easily testable with no need to change the design.
  • Your code might have some bugs. But not for the given input set captured by tests. This limits a lot the potential for bugs.
  • You reached a milestone. Often when 100% of the code is covered, all edge cases are tested and no more tests are needed.

I often hear developers against this 100% coverage dogma claiming they don’t want to lose time writing tests to cover trivial code like property getters. But in reality nobody does that. The question is: why those properties are not covered yet by existing tests?

Some others claim that it takes time to cover the 10% remaining of a class. It just means that such class is not testable and hence, per definition, not well designed.

Another typical debate is that 100% coverage is meaningless because what really matters is what gets asserted in test code. To me the right way to implement 100% coverage is to also assert anything that can be asserted in code. This way so much more assertions get challenged at test time. Moreover test code gets clearer because it focuses on asserting the most relevant states.

In the end measuring progress through the amount of code automatically exercised by tests gamifies the development process and helps clarifying the definition of done.

We’re quite happy to have 86.5% of our code base exercised by our 20K+ tests suite. The heatmap view of what is covered and what is not covered sheds the light on the few remaining error-prone areas that we should improve:

Treemap and Code Coverage Visualization

7. Catch Regression Early

Manual testing is often deemed as smoke testing. It is qualified as smoke because manual testing benefit evaporates as soon as a single line of code is changed in the behavior tested.

On the other hand the key characteristic of automatic tests is – well – they can be automatically passed, again and again. When a code change provokes a test failure either a regression bugs has been introduced or the requirement captured by the test has changed. In the real world, when the code is well tested with a high coverage ratio, most regressions get indeed detected early and automatically. 

This discussion is related to the fact that tests should be fast to execute because they are abstracted from the complex underlying infrastructure. Here is, to me, the difference between unit-tests and integrations-tests: it is ok to have long tests that exercise many layers of your application at once. But you have to keep in mind that they won’t be executed as often as unit-tests that should typically be all passed within a few seconds.

In our code base there is a dedicated unit-test assembly that groups more than 7.000 unit tests. It takes 8 seconds to pass them all. On the other hand we have also some integration tests that take almost a minute to pass like this code dependency graph UI integration test:

8. Refactor with confidence

What makes the software industry different than tangible assets industries like cars and planes, is that software doesn’t rot with time. For example we have many components untouched for more than a decade that still works perfectly.

Software doesn’t rot over time but requirements evolve with time. Software users constantly want more features with less performance consumed. Also the underlying context often changes: .NET code that works fine on Windows now needs to work on Linux and MacOS as well. This is software maintainability.

A consequence of maintainability is the need to refactor: restructuring the working code to be able to handle new requirements. But a major difficulty is to restructure the working code without breaking all past requirements it addresses. When there is no tests, it is not uncommon to see complex classes and methods untouched for years because nobody in the team really knows how it works. But because no user are complaining we know it works. And despite the new requirements need nobody takes the risk to refactor it and break it!

On the other hand, if you have a decent test suite most past requirements are exercised automatically: tests are a safety net for refactoring. No matter how wide is the impact of the needed refactoring, with a solid test suite you can refactor with confidence.

9. Release with confidence

When you start to track how the new and refactored code is actually tested, you relieve yourself from a lot of stress and frictions once the code is released. For that you can run some code rules on your code compared against the last release in production (the baseline), like for example the rule From now, all types added should be 100% covered by tests. Untouched code won’t likely be broken by well tested code modifications. This way you can capitalize on the fact that users are happy with the untouched code and avoid manually re-test everything.

A fortunate consequence is that as long as you are committed to be serious about writing tests for new and refactored code, it is never too late to get the habit of writing tests.

10. Save time and enjoy!

When developers write tests, time is saved at every level. Each previous bullets demonstrates it. Saving time means less frictions with users and the management because new requirements can be implemented more quickly. And with less regression bugs, less time is spent on fixing them!

Moreover it is so much more pleasant to work in a well tested code base. Less regressions, less frictions, less stress, more communications, all those are benefits of writing tests. Essentially you are a developer because you like to automate things. By writing tests you are actually automating a lot software development repetitive and tedious tasks. This way most of your time can be spent on rewarding activities.