Effective Unit Testing
Unit tests are a must-have! You should have close to 100% test coverage! ... Really? In this article, we will cover the benefits and drawbacks of unit tests and you will learn when to actually write some.
Maybe you have been there: You feel guilty because you know you should write unit tests, but you don’t. Testing your code simply sounds so boring, and you fear an overwhelming learning curve.
Then you hear some devs say that writing tests is overrated or even harmful, so they don’t test at all. And, because you are looking for an easy justification, you gladly take on this opinion.
It could also be that you wrote tests in the past, and you did not see any return on invest, but only found them making software changes very hard.
If you find yourself in one of those phases, you are not alone. I went through all of them.
Over the past few years, I adopted testing strategies that not only assured a certain quality of the code I produce, but in some situations also improved my development performance.
The benefits and downsides of unit testing
There are contrary opinions on whether you should write unit tests or whether it is just a waste of time.
As often, I think that the truth lies somewhere between the extremes: Every tool is invented to solve certain categories of problems. So are unit tests.
Let’s have a closer look at the most common points.
The benefits
To get a nice overview of this debate, I asked ChatGPT for the benefits and downsides of unit testing.
What are the benefits of unit tests?
Unit tests provide several benefits in software development. Here are some of the key advantages:
- Early bug detection : Unit tests help catch bugs and issues at an early stage of development. By testing individual units of code in isolation, you can identify problems quickly and address them before they propagate to other parts of the system. This leads to reduced debugging time and more efficient development.
- Improved code quality : Writing unit tests encourages developers to write modular, well-structured, and loosely coupled code. The process of designing testable code often leads to better architecture, separation of concerns, and cleaner code overall. This improves maintainability and readability.
- Regression prevention : Unit tests act as a safety net against regressions. Whenever changes or enhancements are made to the codebase, running the corresponding unit tests ensures that existing functionality remains intact. This helps prevent unintentional introduction of new bugs or issues.
- Enhanced refactoring capabilities : Unit tests provide confidence when refactoring code. Since you have a suite of tests that validate the behavior of individual units, you can refactor or restructure code with greater ease. If the tests pass after refactoring, it indicates that the code still behaves correctly.
- Documentation and code understanding : Well-written unit tests serve as documentation and examples of how the code is intended to be used. New developers on the project can refer to unit tests to understand the expected behavior of various components, reducing the learning curve and aiding in onboarding.
- Facilitates collaboration : Unit tests enable developers to work collaboratively. Different team members can independently write and run tests for their respective components without worrying about interference or dependencies. This parallelism speeds up development and encourages a more efficient workflow.
- Supports continuous integration and deployment : Unit tests are an integral part of continuous integration (CI) and continuous deployment (CD) workflows. They enable automated testing during the CI process, allowing for early feedback on code quality and preventing the introduction of defects into the main codebase.
By providing fast feedback, maintaining code integrity, and fostering code quality, unit tests significantly contribute to the overall reliability, stability, and maintainability of software systems.
Those points are all valid from my experience. But there are also some downsides to unit testing as well.
Downsides of unit testing
What are the downsides of unit tests?
While unit tests have numerous benefits, they also have some potential downsides. It’s important to be aware of these considerations when incorporating unit tests into your development process:
- Time and effort : Writing comprehensive unit tests can be time-consuming and require additional effort from developers. This can slow down the development process, especially when deadlines are tight.
- Maintenance overhead : Unit tests need to be updated and maintained alongside the codebase. As the code evolves and changes, unit tests may need to be adjusted or rewritten to reflect those changes. This can introduce additional overhead and increase the overall maintenance burden.
- Complexity and learning curve : Creating effective unit tests requires knowledge and expertise in testing frameworks, tools, and best practices. It may take some time for developers to become proficient in writing high-quality unit tests, which could impact productivity initially.
- Incomplete coverage : It’s possible for unit tests to provide insufficient coverage, especially when dealing with complex systems or rare edge cases. Developers need to carefully design their tests to cover various scenarios and ensure that critical parts of the codebase are adequately tested.
- False sense of security : While unit tests can catch many issues, they cannot guarantee the absence of bugs or other problems. There may be situations where code behaves correctly within isolated units but fails when integrated with other components or in real-world scenarios. Relying solely on unit tests may create a false sense of security.
- Overemphasis on low-level details : Unit tests focus on testing individual units of code, which can lead to an overemphasis on low-level implementation details. This may result in less attention being given to higher-level integration and system-level testing, which are equally important for overall software quality.
Despite these downsides, the benefits of unit testing often outweigh the drawbacks. With careful planning, good testing practices, and an understanding of the limitations, unit tests can significantly improve code quality, reduce bugs, and enhance the maintainability and reliability of software systems.
Those points are valid as well.
So, there are pros and cons of unit tests. The benefits are very appealing, so we don’t want to miss them, right?
This brings us to the next question:
Which parts should you unit-tests?
Before answering this question, we should be aware of an alternative type of tests: Integration tests.
Unit tests and integration tests
The difference between unit tests and integration tests is their scope.
With unit tests, you concentrate on the single units/components of the software at hand.
Many dependencies are replaced by mocks that implement their API. So, you don’t have any expensive I/O accesses and the implementation code runs on fake data.
With integration tests, you put those units together into a functional piece of software and test their integration. Often you also include external resources like databases because you also want to know whether your queries work as expected. In-memory DBs are preferable here because of speed, and it’s easy to wipe them to prevent inter-test dependencies.
Overall, unit tests run faster and they require less setup. If a test fails, you quickly know where the cause is located, without digesting logs and call stacks.
Integration tests, on the other hand, are more or less black-boxed. They make less assumptions about internal implementation and design, and as such are less affected by refactorings.
Focus on unit-testing your application logic
Let me stress the last point here: Unit tests increase the stability of your interfaces by depending on them. Not only the interfaces of the unit under test become more stable, but also the interfaces of the unit’s dependencies.
Stability can be measured by the number of inbound code dependencies.
In some places of your code, stability is less problematic than in others. And sometimes stability is also wanted: For examlpe, core business rules should be quite stable, as well as the APIs that your software exposes.
With this mindset, it’s a worthy goal to have your business logic separated from framework and library adapters. Architecture patterns like Clean Architecture , Hexagonal Architecture or Onion Architecture can help you structure the code in this direction.
So, it makes sense to write unit tests only for the logical parts of your software and favor integration tests for testing the others. This gives you more room for internal redesign and refactorings.
Having this question answered, let’s have a look at the process of writing tests.
When should you write unit tests?
You may have heard of Test-Driven Development (TDD) before. It is a practice where you go through this cycle over and over again:
- Red: You write a test that makes fails with the current implementation.
- Green: You make the test pass by implementing just as much code to fulfil the expectation.
- Refactor: You refine your implementation and make design decisions to improve the code quality and maintainability.
Personally, I am not a hardcore TDD developer. I tend to do write unit tests first, when the solution quickly comes to my mind. In other situations, when I’m more exploratory seeking for a suitable solution, I have the feeling that writing tests slows me down. Then I write them later on.
In general, it makes sense to write unit tests as early as possible, to have a high ROI. Here are some advantages of this approach:
Think about the interface first
When developing the test beforehand, you immediately have a reference consumer of your interface that you are building. This forces you into a consumer’s perspective. A well-designed interface is valuable, since it is likely to be more often consumed than implemented.
Keep feedback loops small
TDD helps you to gain quick feedback during development. You do not have to manually navigate to the application state you need to test a specific behavior, but you can set up any state to prepare the scenario you want to cover. This can actually save you a lot of time.
Simplify debugging
Unit tests are a great way to debug your code. You can start off from any test, instead of always stepping through a bunch of intermediate states. This can help you in the development phase already, depending on the complexity of your code.
How many unit tests should you write?
When we look at the benefits and drawbacks again, it makes sense to write as many unit tests as required to provide a high coverage of our logical software parts.
We should not rely solely on unit tests to ensure the quality of our application. Instead, we should also complement them with a robust set of integration tests. These tests are particularly important for verifying the functionality of the application’s interfaces, such as REST APIs. In addition to testing internal components, integration tests also interact with external APIs that the application consumes. If we have the ability to control the state of these external APIs (for example, by using in-memory databases), we can test against these instances. Otherwise, we can use mock APIs to simulate their behavior.
Important for these tests is execution speed and determinism, allowing them to be run alongside unit tests in watch mode if desired.
If we have a UI, or external services that our software consumes, we can add some e2e-tests (e.g. cypress) to run real flows through our app. Those tests are normally run in the CI pipeline.
We see this distribution of test classes reflected in the popular test pyramid.
Code coverage
If you like, you can use coverage tools. But keep in mind that different parts of your application should be tested with the matching types of tests.
I sometimes use coverage tools to double-check that the most important branches of my business logic are covered by unit tests. For this, you can use the coverage tools that come with your testing library.
For your CI pipeline, Cobertura XML is a widespread coverage format.
Testing library
If you ask yourself which testing library to use as a TypeScript/JavaScript developer, you can have a read on 5 good reasons to switch from Jest to Vitest . I am very happy with Vitest so far and recommend it to any web developer doing unit or integration testing.
That’s it for today. Keep on coding and never stop learning !