University of Leeds

Introduction to testing

Introduction

Testing code is an extremely important part of the software development cycle. A top-of-the-range car, jammed pack with electronics and sensors, may have upwards of 100 million lines of code. The fact that a 'bug' in the code may cause a fatal accident means that every single block of code will be tested. Indeed, the software engineers will write tests to test the code, before they have even written the code. This is known as test-driven development (TDD). In essence, before a function is written, a test to test the function will be written.

For example, the engineer is told to write a function to calculate the area of a circle and probably be given an API to define its behaviour. The function will have a single argument (the radius) and will return the area of the circle. Before they write the function, they will write a test function that ensures the function returns the correct area for a given radius. For example, a circle of radius 1.0 m has an area of 3.1415926536 m2. So they write a function that passes 1.0 to the function and checks that the return value is 3.1415926536. Several test cases are usually included that use values that may potentially cause problems such as negative numbers, very large numbers (overflow) and zero etc.

The tests that test individual blocks (or units) of code are called unit-tests. The principle is that by testing each function as you go will save you time in the long run. For example, the software engineers working on the car will not write 100 million lines of code and then test it all at once at the end! It would be impossible to track down the errors!

Example

This example will look at writing a test to test a function that calculates the sum of two integer numbers. Although it is an artificially simple example, it highlights the process. A simple unit test may look something like below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// function to test sum
bool test_sum(int a, int b, int expected) {
  std::cout << "sum(" << a << "," << b << ") : ";
  int val = sum(a, b);  // calc value and compare to expected
  if (val == expected) {
    std::cout << "passed\n";
    return true;
  } else {
    std::cout << "FAILED! " << val << " (expecting " << expected << ").\n";
    return false;
  }
}

Here we pass in the two arguments to the function a and b along with the expected (correct) answer. We then pass a and b into the sum() function and store the return value. We can then compare the return value to the expect answer. We then return true or false depending on whether the test passed. Note that there are several debug messages printed to the console to enable the developer to see the results of the test.

We can then write another function to run several of these tests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// function to run the tests
int run_sum_tests() {
  std::cout << "\nTesting sum()...\n" << std::endl;
  // initialise counter for number of tests passed
  int passed = 0;
  // do various tests
  if (test_sum(0, 0, 0)) passed++;
  if (test_sum(-1, 1, 0)) passed++;
  if (test_sum(2, 3, 5)) passed++;
  if (test_sum(826109, 78657567, 79483676)) passed++;
  if (test_sum(-99, -1, -100)) passed++;
  std::cout << "\nsum() passed " << passed << " tests.\n";
  return passed;
}

Here we create an integer counter to count the number of tests passed. We then call the unit test several times with different parameters. If the test returns true, we increment the counter. Note the code style of the if statements, this is fine for short statements like this and can help to condense the code, making it more readable. At the end of the testing function, we return the number of tests passed.

Now we have to actually write the function itself. This example is trivial.

1
int sum(int a, int b) { return a + b; }

Again, note the code styling for this short function.

Results

Now we have written the function, we can test it by running run_sum_tests(). The output of the tests is shown below.

Testing sum()...

sum(0,0) : passed
sum(-1,1) : passed
sum(2,3) : passed
sum(826109,78657567) : passed
sum(-99,-1) : passed

sum() passed 5 tests.

Here we can see that the code has passed all of the tests and we can proceed with more confidence in our code.

Now, imagine we made a mistake with our function.

1
int sum(int a, int b) { return a - b; }

When we ran the tests, we would see the following output.

Testing sum()...

sum(0,0) : passed
sum(-1,1) : FAILED! -2 (expecting 0).
sum(2,3) : FAILED! -1 (expecting 5).
sum(826109,78657567) : FAILED! -77831458 (expecting 79483676).
sum(-99,-1) : FAILED! -98 (expecting -100).

sum() passed 1 tests.

Here we can clearly see that the function is not working correctly and then we can go back, knowing that the problem is in that specific function. Imagine not doing this and then going on writing several more functions before doing some tests. You would not be sure where the error was originating from and then have to spend much more time de-bugging.

Note also how one of the tests passed. Both 0 + 0 = 0 and 0 - 0 = 0. This highlights the importance of including several test cases covering issues such as zeros and negative numbers.