Ode to Unit Tests

Ode to Unit Tests

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 by Max Kanat-Alexander

To check the correctness of the application developers utilize different types of tests: some of them check the logic of a small function, others - all system levels from UI to databases and external services. Some types of tests may be somewhere between calling the application API and using stubs for external services. The number of elements tested determines the scope of testing: fewer elements - smaller scope, more elements - larger scope. The larger the scope of testing, the more computational resources are required and the more time it takes to run tests but the bigger chunks of functionality you can validate. Also, tests with the large scope are difficult to maintain and they were made for a longer feedback loop.

Today we will talk about Unit Tests, which are placed at the bottom of the testing pyramid and have the shortest feedback cycle.

Unit Tests

So, what's the Unit test? It's a code that can verify that another code is working as expected. One of the important qualities of the Unit Test is the following:

  • it verifies the functionality of the application elements — measurement units — classes and functions;
  • it is written by the developers while working on the code;
  • it is easy to run without having to set up an additional environment;
  • it takes little time to execute;
  • easily integrates with CI, as it does not require anything else except the code

Good Unit Tests follow five rules, which form the acronym F.I.R.S.T.

Fast — the tests have to be fast. When tests are slow, you don't want to run them often. And if you don't run them often, you won't find problems early enough to fix them easily. You won't feel safe enough to refactor the code.

Independent (or isolated) — tests should not depend on each other. Developers should be able to run tests in any order (preferably in parallel). When tests are dependent on each other, the first loser leads to a cascade of failures, making it difficult to diagnose and hiding defects downstream.

The Single Responsibility Principle (SRP) SOLID Principles states that classes should be small and single-purpose. This can be applied to your tests as well. If one of your tests may fail for several reasons, consider splitting it into separate tests.

Repeatable — tests should be repeatable in any environment: a production environment, a quality assurance environment, and even on a developer's laptop. Test results should be the same every time and in every location.

Self-validating — each test should be able to determine whether or not the result is the one you expect. They either pass or fail. You do not need to read the log file or compare different files to see if the tests pass. If the tests are not self-validating, you may become subjective and running the tests may require a long manual evaluation.

Thorough/Timely — tests should be written at the right time, with the feature implementation. Post facto testing requires developers to refactor working code and extra effort to have tests that follow these FIRST principles.

Why testing?

Once we know what's being tested, the next question is why? The answer is simple because unit tests help us test the code and find defects. This is true, but there is a less obvious and more serious reason: they help us keep the implemented application running and add new features and fix errors faster.

Ease of maintenance is often described as the ability to easily understand, modify and test an application. Unit tests contribute to all three of them:

  • When modular tests are a test scenario implementation, they help you understand the functionality of components;
  • Unit tests can quickly tell you if a code change affects existing functionality;
  • Development using unit tests forces a developer to design more testable components (often this means better code).

The most common argument against Unit Tests is that they increase development time. It refers to the very beginning of a project when only the initial set of capabilities is implemented and almost every code fragment is written anew. But as the project evolves, you will rewrite the old code more and more often. You will start to spend time trying to understand how the old code works and make sure that your change has not break it.

This is exactly where the tests can help you. Whoever says anything but the time of the operating and supporting your application is incomparably longer than the time of development itself. By writing unit tests you will be in safer place in the future.

Code coverage

Code coverage is a measure used to describe the extent to which a program source code is tested. Usually a program with high code coverage was tested more thoroughly and has less chances to contain errors than a program with low code coverage. But only usually, because even if 100% of the code is covered, we still know nothing about the quality of tests. Some tests may cover the code, but they are useless, have no necessary statements or are just badly designed. It sounds counter-intuitive, but knowing which code is covered is more important. Uncovered code means that there are no unit tests to check that the code works as expected.

This makes code coverage more of a developer's tool to understand what more test scripts are required than the tool that will be used as a quality gateway to continuous integration. It's still a good idea to agree on a minimum level of code coverage to identify hot spots, but you can't blindly trust high code coverage and we have to take care of unit tests quality.

TDD

Test-driven development

Let's talk about the TDD process. In test-driven development, each new functionality starts with writing a test for it. To write a test, a developer must clearly understand the specifications and requirements to a function. This is a distinctive feature of test-driven development: It makes the developer focus on test scenarios before writing the code - a very subtle but important difference.

At the second stage we run all the tests and see if the new test is failing. Then we write the code.

The next step is to write a code that will result in a test pass. The new code written at this stage is not ideal and may, for instance, pass the test in a ridiculous way. At this stage, the only purpose of the written code is to pass the test. The programmer should not write code that goes beyond the functionality the test checks.

Then we run the tests.

If all the test cases pass, the programmer can be sure that the new code meets the test's requirements and doesn't break or affect the existing functionality. If they do not pass, the new code must be modified until they pass. At the next stage we perform code refactoring.

If necessary, we repeat this process starting with the new test and then the loop is repeated to extend 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 could also be hard to apply it to GUI or frontend development. But this is the theme for another post.

Conclusion

So, in conclusion, I can say that unit-testing is a powerful technique that allows you to quickly check the code according to test scenarios, speed up development in the long run and keep the code in working condition.


Daily dose of