The Law of Testing: The degree to which you know how your software behaves is the degree to which you have accurately tested it. -- "Code Simplicity"
In order to verify the correctness of an application, we use different kinds of tests: some check the logic of a small function or class, others check all the system layers from UI to databases and external services. Some kinds of tests could be somewhere in between calling the application API and using stubs for external services. The number of tested elements defines the scope of testing: fewer elements – smaller scope, more elements – bigger scope. The bigger the scope of tests, the more computational resources are required and the more time is needed for running the tests. Also, tests with a bigger scope are harder to maintain and they were made for a longer feedback cycle.
Today we will talk about Unit Tests, which are placed at the bottom of the testing pyramid and have the shortest feedback cycle.
So, what is a Unit Test? It's a code that can check that another code works as expected. One of the important qualities of a Unit Test is the following:
- it tests the functionality of the elements of the application – units - classes and functions;
- it's written by developers while working on code;
- it's easy to run without having to set up an additional environment;
- it requires small time to run;
- it can be easily integrated with CI because it does not require additional environment.
Good Unit Tests follow five rules, which form the acronym F.I.R.S.T.
Fast – tests should be quick. When tests run slowly, you don't want to run them frequently. And if you don’t run them frequently, you won’t find problems early enough to fix them easily. You won’t feel as free to clean up the code.
Independent (or Isolated) – tests should not depend on each other. Developers should be able to run tests in any order you like(even in parallel). When tests depend on each other, the first one to fail causes a cascade of downstream failures, making diagnosis difficult and hiding downstream defects.
The Single Responsibility Principle (SRP) of SOLID principles says that classes should be small and single-purpose. This can be applied to your tests as well. If one of your test methods can break for more than one reason, consider splitting it into separate tests.
Repeatable – tests should be repeatable in any environment: in the production environment, in the QA environment, and even on a developer laptop. Test results must be the same every time and at every location.
Self-validating – each test must be able to determine that the output is expected or not. They either pass or fail. You should not have to read through a log file or compare different files to see whether the tests pass. If the tests aren’t self-validating, then failure can become subjective and running the tests can require a long manual evaluation.
Thorough/Timely - tests should be written at the proper time, with feature implementation. Testing post-facto requires developers to refactor working code and make an additional effort to have tests fulfilling these FIRST principles.
As soon as we know what is tested, the next question is – why? The simple answer is because Unit Tests help us to test our code and find defects. This is true, but there’s a less obvious and more serious reason: they help to keep an implemented application maintainable – add new features and fix bugs faster.
Maintainability is often described as the ability to understand, change and test an application easily. Unit Tests contribute to all three of these:
- When Unit Tests are an implementation of test scenarios, they help to understand component functionality;
- Unit tests can quickly tell you if a change breaks the existing functionality;
- Development with Unit Tests forces the developer to design more testable components (quite often it means better code).
The most common argument against Unit Tests is that they increase development time. This is true for the very beginning of a project when only the initial set of features has been implemented and almost every piece of code is freshly written. But as the project proceeds, you will find yourself rewriting old code more and more often. You will start spending time on trying to understand how the old code works and ensuring that your change hasn’t broken it.
That’s exactly where Unit Tests can help you. Yes, skipping Unit Tests lets you develop faster in the beginning, but if the application is going to be used, for example, for several years, introducing more features along the way, you have to keep the application maintainable and write Unit Tests.
Code coverage is a measure that’s used for describing the degree to which the source code of a program is tested by a particular test suite. Typically, a program with high code coverage has been more thoroughly tested and has a lower chance of containing bugs than a program with low code coverage. But only typically, because even if 100% of the code is covered, we still know nothing about the quality of the tests. Some tests can cover code but be useless, having no necessary assertions or just being badly designed. It sounds counter-intuitive, but to know which code is uncovered is more important. Uncovered code means there are no Unit Test scenarios to verify that the code works as expected.
It makes code coverage more of a developer tool for understanding that more test scenarios are required, than a tool to be used as a continuous integration quality gate. It’s still a good idea to agree on some minimum level of code coverage for identifying hot spots, but you can’t blindly trust high code coverage and we should care about the quality of Unit Tests.
Let's talk about the TDD process. In a test-driven development, each new feature begins with writing a test for it. To write a test, the developer must understand clearly the feature's specifications and requirements. This is a differentiating feature of test-driven development versus writing Unit Tests after the code: It makes the developer focus on the test scenarios before writing the code – a subtle but important difference.
For the second step, we run all the tests and see if the new test fails. Then, we write the code.
The next step is to write some code that results in the test passing. The new code written at this stage is not perfect and may, for example, pass the test in an inelegant way.
At this point, the only purpose of the written code is to pass the test. The programmer must not write code that’s beyond the functionality that the test checks.
Next, we run tests.
If all test cases now pass, the programmer can be confident that the new code meets the test requirements, and does not break or degrade any existing features.
If they do not pass, the new code must be adjusted until they do.
For the step after this, we refactor code.
If necessary we repeat this process, starting with another new test, and the cycle is then repeated to push forward the functionality. TDD approach has some limitations, for example, it's hard to apply it to big data & data science projects, because of data-driven part of those applications, it also hard to apply to GUI or frontend development. But this is the theme for another post.
So, in conclusion, Unit Testing is a powerful technique that allows one to quickly verify code through test scenarios, speed up development in the long term, and keep code in a maintainable state.