Unit Testing

Testing is a continuous activity and it is done both as a part of the development of program functionality and automatically always when new functionality is added to the main line in the remote repository. The goal is a so called self testing code where the automatically executable tests thoroughly testing the functionality of the program are an inseparable part of a functional program. Making testing an integral part of software development adds confidence in that the program is continuously in a functioning state: if the program passes its tests there are no major faults in it. Implementing the software consists not only of implementing the functionality but also of implementing a fault detection system for it. This approach to testing is a part of the principles of continuous integration and continuous software development.

One of the main advantages of self testing code is that is reduces the amount of such bugs that end up undetected all the way up to production. Even more importantly with this testing approach the developer can make changes into to code without needing to worry about breaking something in it. If the programmer makes a mistake, it is caught by the continuous testing immediately and can be fixed. The developers are spared of regression i.e. that adding some feature ends up breaking earlier functionality. The code quality is improved and the development work becomes faster through the conficende testing brings.

As a direct consequence of testing not being a separate stage from software development a good programmer is able to test their own code. Especially unit testing is the responsibility of the developer of the unit: while the component itself is implement also the tests to test it are implemented. Often software developers have other quality ensurance tasks for the code of other members of the team. For example every addition to the mainline goes through the review done by another team member as a pull/merge request. The overall aim in modern testing is to build such development processes for the team that implementing functionality goes hand in hand with writing the tests for it. Testing is hence something addressed on the work culture level.

Unit testing in practice

Unit testing is a part of the development phase of a software unit. In practice the tests are always run on the developers own machine locally and then automatically at every push to the remote reposity as a part of continous integration. The interface gives a good viewpoint to making unit testing. Encapsulation hides the implementation behind a uniform interface. The interface itself is quite invariant and the component is in any case used through the interface.

One way to approach unit testing as a part of software development is Test Driven Development (TDD). TDD is a method for developing software literally driven by testing: The tests testung the feature drive the development work further. In test driven development the following steps are repeated iteratively one test at a time adding more functionality into the software:

  1. Write a automatically executable test for the functionality that needs to be added next and also run it. First time around it will naturally fail.

  2. Write the functional code for the feature and run the tests until the tests pass.

  3. Fix and refactor the code in the software toe improve its structure

Stage three is integral and must not be omitted. Refactoring means changing the structure of the code so that its functionality does not change. It improves the non-functional quality of the code making the amount of so called technical debt smaller. Many problems hiding in the code can be detected and fixed at the same time. Technical debt in turn is a metafore for each shortcut, quick and dirty, poorly thoughtout implementation desicion with which the functionality of the software can be increased causing its internal structure to deteriorate at the same time. Like any debt, technical debt is both useful (it enables implementing the feature) and must be paid with interest sooner or later. Refactoring reduces technical debt as it improves the technical quality of the code before the amount of debt has risen into a unmanageable level.

Implementing unit testing

It is not possible to test everything. For example the value ranges of the input elements alone are so large it is not even theoretically possible to test them fully. When designing test cases the test input needs to be limited to test the functionality of the unit as well as possible with a mangeable amount of tests.

There are good practices for selecting test cases. Two central are:

  • Equivalence partitioning

  • Boundary value analysis

In equivalence partitioning the forming test cases is started by dividing the input into equivalence classes. The classes are selected so that the target code tested handles all the input elements in the class in the same way. Thus it is enough to test the code with one representative of the class and the result it yields represents the functionality of the code with all elements in the class. The dividion into equivalence classes is done by recognizing the possible value range for each input element (parameter, condition) and dividing it typically with the basic split into allowed and forbidden values. When the class division is precise enough a representative is selected from each class and the test cases are written using them.

Equivalence partitioning

when the input consists of several equivalence classes the typical source of errors in the code is in the boundaries of the classes. Boundary value analysis focuses on these boundary values. Such include:

  • Boundary values of parameters and return values

  • Boundary values of loop conditions

  • Boundary values of for data structures

For example, if a legal input covers the values 10-99 it makes sense to test the code with 9, 10, 99 and 100.

Testing boundary values

When writing tests it is good to focus on implementing small, clear test functions and simple checks in them. If assertions are used in the tests it is important to place them into the test code. It is also important to keep in mind that test code is code. It needs to be documented, tested and maintained like functional code. Test code can also be erroneous:

TEST_TYPE test_square_root() {
    double result = my_sqrt(x);
    ASSERT_TRUE((result * result) == x);
    // A small bug in the test code (what?)
}

When coming up with test cases a good starting point is to think what is the most important thing for the method and then to test the most usual cases. It is also worth being as creative as possible as the errors are typically found on the sidelines of clear execution paths. It is worth focusing on the interfaces of the components in testing. The offer a clear “black box” to the functiontality. You know nothing about the functionality itself but from the interface you know how the services of the interface should be called and what can be expected from their functionality. It does not pay off to get too complicated with the tests. Instead it is worth keeping the tests as simple as possible. It also sensible to use a test framework. For Java such a framework is JUnit.

Unit testing (duration 27:25)

Static analysis

In static analysis the code is investigated without running it. There are different analysis tools available for programming languages. One such a tool is SonarQube. The aim is to analyse the overall quality of the code and catch such errors that are poorly detected by functional testing.

Measures used in static analysis include:

  • Code smells: any point in the code that can contain a deeper issue. The smells are not necessarily bugs. They contain no functional issues. They are still on the level of code fuzzy, weak or otherwise faulty that can make development harder of increase the risk for bugs i.e. they smell bad.

  • Technical debt: illustrates the amount of improvement work needed in the code. This work includes the needed refactoring, technical improvements needes, adding code clarity and other such maintenance work. Technical debt is considered work that needs to be done at some point if problems want to be avoided. The debt must be paid.

  • Coverage: tells how widely the unit tests when run test the functional code.

The analysis reveals among others:

  • Uninitialized variables

  • Return values not used

  • Erroneous use of pointers

  • Same code in several places

  • Code that is never executed i.e. dead code

  • Problems in maintenance and porting

  • Security and safety problems

Testing software

In unit testing