Prev Next

Debugging and Testing

Measure twice, cut once

Since computer programs are easy to change and to run, it is tempting to throw something together and begin testing and debugging right away. If you do that, you will spend far too long debugging. Try to be cautious about how you program. After you have written a function definition, for example, reread the definition, and convince yourself anew that the function is written correctly. Look for errors, such as memory allocation errors. Shoot for having the function work the first time, without going to extremes of hand checking that take too long.

The best laid plans...

In spite of your best planning, you will make mistakes, and you should plan for them. To fix your program, you will need to use debugging strategy and tactics.

Debugging strategy tells you the large steps involved in debugging. The steps are as follows.

  1. Determine that something is wrong. Simplify if possible to a small input that the program gets wrong.

  2. Isolate the error as closely as possible within the program. For example, you might determine which function is at fault, or at least narrow it down to a few functions.

  3. Determine exactly what is wrong.

  4. Determine what can be done to fix the program.

Debugging tactics are particular ways to accomplish the goals of a strategy. Usually, isolating the error is the hardest thing to do. Tactics that follow are some tactics for isolating errors. They also help in step 3, determining exactly what is wrong.

Tracing

Add prints to your program to show what is happening. Direct the prints to a file, not to the standard output. You might let the main program open a file, and make that file available to all modules using an extern declaration. This is one place where using a global variable is justified.

Be sure that you do not print raw data. Each added print should show who did the print, where it is, and what is being printed. For example, if you insert a print at the top of a loop body in function copy, and you are printing the value of an integer variable n, you might write

fprintf(trace_file, 
        "copy: Loop top: n = %d\n", n);
If you are printing an array A of n integers at the beginning of function insert, you might write
{int i;
 fprintf(trace_file, 
         "insert: Begin, A =\n");
 for(i = 0; i < n; i++) {
   fprintf(trace_file, 
           "A[%d] = %d\n", i, A[i]);
 }
}

Hint: If you define a preprocessor symbol such as

#define tprint fprintf
then you can use tprint in place of fprintf in your trace prints. The advantage is that you can then search for tprint using a text editor or search utility, and quickly find all of the trace prints.

Hint: You can leave trace prints in your program if you surround them by preprocessor conditions. For example, you might write

#ifdef DEBUG_COPYING
  fprintf(trace_file, 
          "Begin copy\n");
#endif
Now if you have said
#define DEBUG_COPYING
then this print will be compiled in. If not, then it will not be compiled.

Using a debugger

A debugger can be a powerful tool if used correctly. There are many different debuggers, each with its own characteristics. Typical things that a debugger will do for you are

  1. Show where a program is when an error occurs.

  2. Show the values of variables.

  3. Allow you to stop the program at selected places.

  4. Allow you to step through a program.

Debuggers rely on extra information being put into executable files, telling the names of functions and variables. When you compile a file, you should be sure to tell the compiler to include debugging information. In Unix, for example, you use option -g on the compile command line. For example, you might say

  g++ -g -o myprog program.cc
to compile C++ program program.cc, and put the executable code into file myprog.

An example of a debugger is the Gnu debugger (gdb). To debug program myprog using gdb, you type

  gdb myprog
The debugger has its own language. Use the following with gdb.

  1. Command run runs the program.

  2. Command bt shows the run-time stack, most recent frame first, when the program encounters an error.

  3. Command print x prints the value of variable x.

  4. Command break name causes the program to pause, and return control to the debugger, when the program enters function name.

  5. Command cont continues the program after a pause.

  6. Command help provides information about more commands.

Type ctl-C to pause a program while running it via gdb.

Successive refinement

Successive refinement is a method of developing a program gradually. After each stage, you have a working program that does part of the job, or that at least incorporates some of the parts that the full program will hold.

To develop a program by successive refinement, start with a refinement plan. Think about the parts, and how they can be tested. Write each part, and test each before moving on.

Often, your plan calls for developing a sequence of approximations. Each approximation does a little more of what you want the entire program to do. An approximation might add one more feature, for example. Make sure each approximation is working before moving to the next one.

A big advantage of this is that isolation of errors is usually easy. The error is usually in the (small) part that you have just written.

Code inspections

One way to find an error is to read your program carefully to see if it looks correct. Try hand executions. For some errors, this approach will show you the error faster than any other method. But be sure not to give up if code inspection does not reveal to you what is wrong. You can fall back on tracing or running a debugger.

Which should I use?

You will need to develop, though experience, a feel for which is more useful in each situation, a debugger, tracing or code inspection.

  • If you get a memory protection fault, a debugger will tell you where the fault is very quickly.

  • If you have an infinite loop, use a debugger, and break the program where you think it is in the loop. Look a the run-time stack.

  • If you have a subtle algorithm flaw, you will often find that a trace is more effective.

  • If you have just written a piece of code and things seem very bad, sometimes a code inspection and hand simulation of that piece will reveal the error quickly.

Testing

Never assume that your program works. Always test it. When you design a program or a part of a program, you often have one or two example inputs in mind, to help you think about the problem. Since you understand those example inputs, you test your program on them. It is very tempting to think that your program works once it has successfully handled those examples. But

You should not assume that your program works after trying only one or two examples.
Try several examples, including some that you think might be difficult, or might expose a bug.

Check the results, and see whether they are right. Try extreme examples. Keep in mind that whoever uses your program will probably use it on examples that are unlike your original one or two test cases.

Write your software for testing, and keep your tests around, even after they have been passed. You might modify the program later, and need to retest it to see that your modifications have not made it fail on cases where it previously worked.

Wherever possible, write testers that do not require human interaction. Software designers use regression tests, which are a collection of tests that can be run automatically. Each time you make a modification, you run all of the tests again. A makefile is a good way to control running the tests. You put commands to run all of the tests in a makefile. Then running the tests is as hard as typing

  make test


Prev Next