fbpx
How to avoid common pitfalls that make testing difficult? Part five

This article is the fifth part of the entire entry. If you haven’t read the first part, look here, here, here and here, and let us know if it is helpful to you!

Unlike the rules described in the previous posts, this one is more abstract and leaves more room for interpretation. The essence of unit testing is testing those functionalities that classes make available to the outside world. Hence, individual public methods of classes should be treated as a single functionality. Of course, functionalities can be considered at different levels of abstraction, therefore the public method should correspond to functionalities at a given level of abstraction. For example, a single function can be both the calculation of the total remuneration and the calculation of the component of remuneration in the form of a bonus, however, these functionalities represent two different levels of abstraction.

It should be remembered that developer writing unit tests should ensure that the tests cover all layers of abstraction. It is easy to imagine a situation where the correct result of a method, which is covered by tests, is used incorrectly by a method at a lower level of abstraction, for which tests were not provided. As you can see, writing tests is important not only for atomic functionality at the highest level of abstraction, but also for all subsequent functionalities that use it. Only then the developer can be ensured that the entire data flow will be, with high probability, error-free.

Considering the need to test each functionality independently, the developer should avoid implementing whole functionalities in private methods. The application of this rule, in addition to the obvious benefit of being able to cover more code with tests (private methods cannot have dedicated unit tests), has at least one more advantage. Thanks to this approach, you can avoid writing overgrown god classes that multiply functionalities hidden in private methods. Classes that combine many levels of abstraction are errors prone and very problematic in maintenance.

Developer makes a decision as to what should be tested. However, in order to provide reliable solutions, every functionality of the program should be tested. In practice, the software has a layered structure and a single functionality can be implemented in stages, where each stage provides certain results based on the calculations made. This understanding of the problem is a kind of hint on how to divide the code into smaller methods and functionalities. Each individual component of the final result should be written as a separate public method that is either a part of the class at a higher level of abstraction, or at least, a part of the component whose implementation can generate errors. Classes consistency should also be kept in mind when dividing a complex algorithm into smaller methods. The components of individual classes should be focused on a specific domain or functionality. In practice, this means that most classes will contain one or a maximum of several public methods.

Let’s check the example code of the SalaryCalculator class below. One should note that the public function CalculateSalary has only one task, which is to calculate the total monthly salary. The calculation of deductions and bonuses has been delegated as subtasks to separate classes at a higher level of abstraction.

public class SalaryCalculator : ISalaryCalculator
{
    private const decimal BaseSalary = 2500;
    private readonly IWorkTimeStorage _workTimeStorage;
    private readonly IBonusCalculator _bonusCalculator;
    private readonly IDateTime _dateTime;
    private readonly IPenaltyCalculatorFactory _penaltyCalculatorFactory;

    public SalaryCalculator(
              IWorkTimeStorage workTimeStorage,
              IBonusCalculator bonusCalculator,
              IDateTime dateTime,
              IPenaltyCalculatorFactory penaltyCalculatorFactory)
    {
        _workTimeStorage = workTimeStorage;
        _bonusCalculator = bonusCalculator;
        _dateTime = dateTime;
        _penaltyCalculatorFactory = penaltyCalculatorFactory;
    }

    public decimal CalculateSalary(Employee employee)
    {
        if(_dateTime.Today().Month == 2)
        {
          return BaseSalary;
        }

        var penaltyCalculator = _penaltyCalculatorFactory.Create(employee.JobPosition);
        var workHoursInCurrentMonth = _workTimeStorage.GetWorktime(employee, _dateTime.Today().Year, _dateTime.Today().Month);
        return employee.Stake * workHoursInCurrentMonth + _bonusCalculator.CalculateBonus(employee) – penaltyCalculator.GetPenalty(employee);
    }
}

Such a procedure allows not only to test the method of calculating salary, but also to independently test the ways in which bonuses and deductions are calculated. In practice, the CalculateSalary method tests boil down to verifying whether this method uses correctly the results returned by the methods calculating bonuses and deductions. Thanks to this, the developer does not have to wonder whether the results of the GetPenalty and CalculateBonus methods are correct when writing the CalculateSalary method test – this is a task for separate unit-tests dedicated to these methods. When writing a unit test of a method using external dependencies, it should be assumed that the results of external methods are correct, and calls of dependencies should be mocked instead of using their true implementations – each dependence should also be tested by separate unit tests.

If the code presented above was not divided into separate classes that implement the functions of calculating deductions and calculating bonuses, the programmer would encounter a number of difficulties. Testing the CalculateSalary function, which also calculates bonuses and deductions, could fail for three different reasons, due to:
– error in the salary calculation algorithm,
– error in the bonus calculation algorithm,
– an error in the deduction calculation algorithm.

Although such a test is undoubtedly useful, using the rules described in this article could be much more helpful clearly indicating to the developer which part of the program is wrong. Certainly, the monolithic CalculateSalary function written in this way would be more difficult to discover all the edge cases of the algorithm, which is key to creating high-quality unit tests.

When writing unit tests, one should be guided by the principle that a failed test should give the programmer a definitive answer as to what went wrong. If it is not possible to write such a test, it is a clear signal to divide the tested method into smaller fragments by separating individual functionalities from it.

Where possible, functionalities should be delegated to public methods at a higher level of abstraction. Do not use private methods to implement all functionality.

Summary of the series

The series presents the basic issues, the use of which brings a number of benefits in professional solutions, from ensuring high-quality code to enabling the writing of high-quality unit tests into the resulting code. We described the typical difficulties that accompany the developer when writing the code which should be covered with unit tests. The application of the described rules in conjunction with writing unit tests can have a positive impact on the work efficiency of programmers and improve the reliability of the created software. As code coverage increases with unit testing, not only its fault tolerance, but also its, often underestimated, code quality increases. It is worth noting that it is easier to make changes and new functionalities to high-quality code. Also, it seems equally important that its easier to introduce new programmers into the project with well-written code. The implementation process, thanks to creating high-quality code is much easier, and new developers become independent faster because the high test coverage makes them less afraid of errors generated by their changes.

Stay tuned! Next part of the article in a week!
Let know us what do you think about this article? Share your knowledge and experience with us: