16. Testing and Debugging
I never make stupid mistakes. Only very, very clever ones.
16.1. Fixing bugs
This chapter is about finding, fixing, and preventing bugs in software. Within this book, this chapter is unique in that it’s not based on concrete problems with straightforward solutions. Finding and fixing bugs in software, especially concurrent software, is a problem no one’s solved completely. The first half of this chapter will focus on common bugs and how to fix them. The second half will focus on design techniques for preventing bugs and testing techniques for finding bugs hidden in your code.
16.1.1. Common sequential bugs
We’ll begin with bugs commonly found in sequential programs, some of which have been mentioned in previous chapters as common mistakes. We won’t discuss syntax errors or any other errors which can be caught at compile time. Instead, all bugs discussed in this chapter are run-time bugs.
There’s an infinite number of possible bugs, so they can’t all be listed. Below are a few of the most common categories of bugs to affect beginner (and occasionally veteran) programmers.
- Precision Errors
-
Floating-point numbers have limited precision inside of a computer. Programs that assume infinite precision might break when real results are slightly larger or smaller than expected.
- Overflow and Underflow
-
The cousin of limited precision in floating-point types is the limited range of values that integer types can take on. If a value goes too high, it wraps back around and becomes a negative number. If a value becomes too low, the opposite can happen.
- Casting Errors
-
Casting can have many subtle effects in a program. Sometimes programmers divide two integers and forget that the result is an integer. Incorrectly casting objects can also lead to a
ClassCastException
. - Loop Errors
-
Loops are a favorite place for bugs to hide. Three of the most common loop mistakes are:
-
Off-by-one errors: The loop executes one more or one fewer time than expected.
-
Infinite loop: A classic. The loop continues executing until the program runs out of memory or a user stops the program externally.
-
Zero loop: The loop doesn’t execute even once, though the programmer expected it to.
-
- Equivalence Testing Errors
-
If a programmer uses the
==
operator to compare two references, it will be true only if the two references point at the same object. Instead, theequals()
method should usually be used to compare the attributes of the objects pointed at by the references. - Array Errors
-
Arrays are often involved with loop errors. Two problems specific to arrays are:
-
Out of bounds: A math mistake or an off-by-one error could lead to an attempt to access an element of an array that comes before index 0 or after the last index.
-
Uninitialized object arrays: Arrays of primitive data types can be created and used immediately; however, arrays of object types are filled with
null
values until each element is assigned an object, often a new object.
-
- Scope Errors
-
Some errors can be caused by a programmer’s misunderstanding about the visibility of a variable.
-
Shadowing variables: If a local variable is declared with the same name as a field or class variable, changes within a method will be made to that local variable. This principle is straightforward, but if a programmer doesn’t notice the shadow declaration, the behavior of the code can be very confusing.
-
Reference vs. value: When primitive data is passed as an argument into a method, its value is copied into the method, and the original data is unchanged. When an object is passed as an argument into a method, the reference is unchanged, but methods called on the object can still affect it.
-
- Null Pointer Errors
-
By this point in your programming experience, you’ve almost certainly experienced a
NullPointerException
. This last kind of bug is, in many ways, a catch-all category because a null reference is generally not due to a simple typographical error. In the simplest case, a reference simply hasn’t been initialized with some default constructor, but more often there’s a logical error in the design of the code.
16.2. Concepts: Approaches to debugging
When you’ve discovered the existence of a bug, pinning down its cause can be difficult. There are a number of different techniques that are useful for narrowing down the possible problems.
16.2.1. Assertions
A common cause of program errors is an incorrect assumption by a programmer. A programmer can assume that the user will only enter positive numbers, that a library call won’t throw any exceptions, or that a linked list is never empty. Some assumptions are reasonable, but it’s important to make sure that they’re correct.
Using assertions is a way to check some of your assumptions, often those surrounding a method call. In most languages, an assertion tests a condition. If that condition is true, nothing happens, but if it’s false, the program shuts down or an error or exception is thrown. Using assertion statements is particularly important with methods because you want to be sure that both your input and output are in the ranges you expect them to be.
Java supports assertions natively, as we’ll discuss in the next section. However, virtually every language allows you to create your own assertions should they not be present as a language construct.
16.2.2. Print statements
Computer programs execute quickly. Even if they executed a million times slower, no human is sensitive enough to decipher the flow of electrons inside the processor as a program executes. The simple truth is that we have no idea what’s really happening when our programs execute. We believe that we understand our programs well, and the output usually confirms that our programs are doing what we imagine they should be doing.
If the output doesn’t match our expectations, we might not have enough data to understand the problem. As long as there have been programmers, they have been printing out additional debug information to find their errors. This technique can go a step beyond simple assertion statements because you can print out the values of the variables rather than just test to see if they’re in a range.
Once you’ve found the error in your code, it can be tedious to remove
all of your debug statements. Some programmers use a special print
command that can be turned off using a global variable or compiler
option. Others send their output to stderr
so that it doesn’t
interfere with the legitimate output of the program. Much depends upon
the system, the language, and the individual tastes of the programmer.
16.2.3. Step-through execution
With the rise of modern debugging environments, using print statements has lost some of its appeal. Most languages allow the programmer to run his or her program in a special debug mode where it’s possible to execute a single line of code at a time. These tools usually give the option of stepping over method calls or stepping into them on a case by case basis.
As the program executes, the programmer can inspect the values of the variables in the code. This method of debugging is excellent because it allows the programmer to watch the execution of the code at whatever pace he or she desires. Pinpointing problems becomes trivial if you know which variables you need to watch.
Despite the power of this technique, it has critics. Some older programmers look down on these tools because they make new programmers lazier and, in some cases, less careful about writing code correctly in the first place. It should be noted that step-through execution modes are not available for every language or for every system. Some embedded software or operating system programming cannot be debugged on the real hardware in this way. Of course, most of these systems can be run in virtual environments that do allow step-through debugging.
Even when step-through debugging is available, there are difficulties
that can limit its effectiveness. If the bug occurs sporadically,
perhaps due to race conditions, a programmer might not know where to start
looking. Certain data structures such as the list
template in C++ might
not be easily traversable using the inspection facilities of the
debugger. Likewise, the bug or the source of the unexplained behavior
could be buried in library code. The debugger doesn’t always have
access to library code for stepping through.
16.2.4. Breakpoints
Breakpoints are a feature of step-through debuggers designed to make them easier to use. A user can specify a particular line of code (with some restrictions) as being a place where the debugger should pause execution. Debuggers typically rely on at least one breakpoint in order to skip all the preliminary parts of the code and skip straight to the perceived trouble spot.
Sometimes an error will crop up predictably after many thousands of iterations of a loop or unpredictably due to race conditions or user input. For either of these cases, conditional breakpoints can be used to save the a programmer a great deal of time. Rather than always pausing execution on a given line, a conditional breakpoint will only pause if a certain condition is met.
16.3. Syntax: Java debugging tools
16.3.1. Assertions
As we mentioned before, many languages have assertions as a built-in language construct. In Java, there are two forms this feature takes. The simpler can be done by typing the following.
assert condition;
In this case, condition
is a boolean
value that’s expected to be true
for the program to function properly. The more complicated form of the
feature can be used as follows.
assert condition : value;
This form adds a value that can be attached to the assertion to give the user more information about the problem. This value can be any primitive data type, any object type, or a statement that evaluates to one of the two.
If you’ve never used an assert
statement before, you might want to
test it out by forcing an assertion to fail. You might try the following.
int x = 5;
assert (x < 4) : "x is too large!";
Then, if you compile your program and run it through the JVM, you’ll
be shocked when absolutely nothing happens. Actually, some of you with
older Java compilers might have heard complaints when you tried to
compile this code. If you have a Java 1.3 compiler or earlier, it’ll treat
assert
like an identifier. Some old Java 1.4 compilers might also give
warnings or require special flags to compile. However, if you
have an up-to-date compiler, the problem is that the JVM must have
assertions enabled at run time. Assertions are intended to be a special
debugging tool and ignored otherwise. To turn run program
AssertionTest
with assertions enabled, type the following.
java -ea AssertionTest
With this option, an exception should be thrown at run time.
Exception in thread "main" java.lang.AssertionError: x is too large!
If you’re using an IDE like Eclipse or IntelliJ, you’ll need to enable
assertions on the run configuration for the program, usually by putting -ea
in the field for JVM arguments. There are other options allowing you to
enable or disable assertions for specific packages or classes.
Now that you know how to use assertions, you need to know when they’re
a good idea. The Java Tutorials
on the Oracle website suggest five situations
where assertions are useful: internal invariants,
control-flow invariants, preconditions for methods, postconditions for
methods, and class invariants. Internal invariants are those
situations when you assume that reaching a certain place in your code,
like the else
branch of an if
statement, will force a variable to
have a certain value. For internal invariants, you assert that the
variable has the expected value. A control-flow invariant means that
you assume that your code will always execute along a certain path. For
control-flow invariants, you assert false
if the JVM reaches a point
in the code you expected it never would. Method preconditions are
those conditions you expect to be true about the state of objects or the
input to a method before the method is called.
The philosophy of Java is that public
methods should not have
assertions used to test their preconditions. Instead, illegal input
values for a public
method should cause exceptions to be thrown, so
that improper usage can always be dealt with. In contrast, method
postconditions are the states that various variables and objects should
have at the end of a method call. Using assertions to check these values
is fine, since they reflect an error on the part of whoever wrote the
method. Class invariants are conditions about the state of every
instance of a class that should be true as long as the class is in a
consistent state. Perhaps a method call rearranges the innards of an
object, but by the end of the method call, the object should be
consistent again. You should use assertions to check class invariants at
the end of every method that could make the object violate these
invariants.
Wonderful as assertions are, there are times when they shouldn’t be
used. The key danger of assertions is that they’re usually turned off.
Thus, any statement that’s part of an assertion must not have
side-effects that are necessary for the normal operation of the program.
For example, imagine you have an object called bacteria
that
mutates periodically. The mutation returns true
if successful and
false
if there was an unexpected error. You should not test for that
failure inside an assert, as follows.
assert bacteria.mutate() : "Mutation failed!";
With assertions disabled, the entire assertion is skipped, and the
bacteria
object won’t mutate.
Instead, your assertion should test only the result of the computation.
boolean success = bacteria.mutate();
assert success : "Mutation failed!";
As stated above, checking for bad input coming into public
methods
shouldn’t be done with assertions because turning off assertions will
remove your error checking.
16.3.2. Print statements
Print statements are one of the most time-honored methods of debugging and remain a quick, dirty, yet effective means of finding errors. Java does not provide any special tools to make print statements easier to use for debugging. Some purists might argue that this kind of debugging which focuses on progressively narrowing down the location of a problem until the bad assumption, logical mistake, or typographical error can be found should be done only with assertions.
Nevertheless, there are a few tips to make print statements a better
debugging tool in Java. The first is the use of System.err
. By now,
you’ve used System.out.print()
and System.out.println()
so many
times that you’re probably tired of them. Any output method that can be
used with System.out
can also be used with System.err
. For example,
there’s a System.err.print()
and a System.err.println()
method. If
you simply run a program from the command line and watch the output, you
should see no difference between using System.out
and System.err
.
However, if you redirect the output of your program to a file using the
>
operator, only the System.out
code will be sent to the file.
Anything printed with System.err
will be sent to the screen.
Alternatively, you can redirect System.err
to a file by using the 2>
operator. Using System.err
makes it easier to separate legitimate
output from error messages, but it also makes it easier to comment out
your debug code by doing a find and replace.
A more extensive method for using print statements to debug is by
defining your own class for printing. Every method in it can call a
corresponding method in System.out
or System.err
. You can define a
boolean
value at the class level that determines whether or not
methods in your debug printing class print or stay silent. When you want
to change from debugging to your production or retail version of the
code, you can simply switch this value to false
.
A “modernized” method of using print statements is creating a simple
GUI instead. In preparing materials for this textbook, we were
occasionally frustrated by the fact that multiple threads can interfere
with each other while printing on the screen: You can’t always tell
which thread is printing which characters. By displaying the output of
each thread in separate JTextArea
or JLabel
components on a simple GUI,
you can disentangle the output of each thread.
16.3.3. Step-through debugging in Java
Since the Eclipse and IntelliJ IDEs are so widely used, we’re going to review the step-through debugging features of each environment. Similar tools are available with other IDEs and for most languages.
Eclipse
In Eclipse, you can set set breakpoints either by right clicking or double clicking on the shaded bar immediately to the left of the line you’re interested in or by selecting Toggle Breakpoint from the Run menu. If you try to set a breakpoint on an empty or ineligible line of code, Eclipse will set it on the next legal one.
To debug a program in Eclipse, right click on the file you wish to run in the Package Explorer and select Debug As Java Application. If your program’s already set up to run, you can simply click the Debug button in the toolbar. Whenever you hit a breakpoint, Eclipse will switch to the Debug perspective if it’s not already there.
Once execution is suspended on a breakpoint, you can use the commands Resume, Step Into, Step Over, and Step Return, either from the Run menu or from the toolbar. The Resume command (or F8) allows the program to continue execution, until it hits another breakpoint. The Step Into command (or F5) advances the execution of the program by one statement, moving into a method is there is a method call. The Step Over command (or F6) also advances the execution of the program by one statement, but it skips over method calls. The Step Return (or F7) command advances the execution of the program to the end of the current method and returns, popping the current method off the stack. In the Run menu, there’s also a useful Run to Line command, which will run code until execution reaches the line where your cursor is.
By right-clicking on a breakpoint in Eclipse, you can access its properties. Though properties, you can specify that a breakpoint only halts execution when a specific condition is true or only for a specific thread. Eclipse also provides variable watch and inspection options. Simply by hovering over a variable, its type and value are displayed. You can also inspect an object and traverse its fields. The Variables pane will show the values of local variables, and the Breakpoints pane will show active and inactive breakpoints.
The Debug pane will show a view of the stack for each thread. By moving up and down the stack, the local variables will change depending on which method call you’re currently inside of. By default, the method call displayed will be wherever code most recently executed, but it’s useful to jump back to the method that called the current method (and the method that called that, and so on) to better understand the overall state of the program and why the current method call has the parameters that it does.
IntelliJ
The debugging facilities provided by IntelliJ are similar to those found in Eclipse, and many developers prefer them. You can click in the space to the left of a line of code to set or remove a breakpoint, and you can edit its properties with a right-click.
You can start your program running in debug mode by clicking the Debug button on the toolbar or selecting Debug (Shift+F9) from the Run menu. When execution of your program reaches a breakpoint and pauses, you can use essentially the same tools to advance execution as you would in Eclipse.
The Resume Program command (or F9) will continue execution. The Step Into command (or F7) will advance execution by one line, moving into a method call if there is one. IntelliJ also has a Smart Step Into command (or Shift+F7) that allows you to pick which method call to step into if there’s more than one on the next line. The Step Over command (or F8) advances execution by one line but does not move into a method call. The Step Out command (or Shift+F8) returns from the current method. IntelliJ also has a Run to Cursor command that will try to execute code until reaching the line where your cursor is. All of these commands can be found in the Run menu, and the most common ones appear on toolbars.
In addition, IntelliJ has “force” versions of most of these commands which step over a line of code, step into a method call, or run to your cursor, ignoring breakpoints that might pause execution.
IntelliJ has a Frames pane similar to the Debug pane in Eclipse. It allows
the user to select a given thread and jump to different method calls in the
stack for that thread. Likewise, IntelliJ has a Variables pane that shows the
state of the local variables for the current method call. One of the features
that developers like the most about IntelliJ is that it displays the values that
variables have on each line where they’re used. In other words, a line of code
that adds variables x
and y
will display their values off to the side of the
line of code, updating the display each time the line’s executed.
16.3.4. Examples
Experience is often the difference between a good programmer and a bad programmer. Having seen a bug before means you know to expect it in the future. Don’t be discouraged if it takes hours to find a bug and squash it. Although that time is stressful, spending hours poring over your program makes you better at reading code, a skill just as valuable as writing code. And when you’ve spent hours trying to fix a simple mistake, you’re unlikely to make the same mistake again. To make it easier to spot some of these mistakes, we give a few examples corresponding to the common bugs listed in Section 16.1.
Floating-point precision can cause subtle errors.
Here’s an example of a program attributed to Cleve Moler that gives
some estimation of the threshold for floating-point precision. Note that
a
≈ 4/3, making b
≈ 1/3, c
≈ 1, and d
≈ 0. Nevertheless,
the comparison d == 0.0
in the if
statement in this code will
evaluate to false
.
double a = 4.0 / 3.0;
double b = a - 1;
double c = b + b + b;
double d = c - 1;
System.out.println(d);
if(d == 0.0)
System.out.println("Success!");
The output for this fragment is -2.220446049250313E-16
, and it would be
much worse with float
variables. Computer scientists who specialize in
numerical analysis have tricks for minimizing the amount of floating-point
error introduced, but awareness is an easy solution to these kinds of bugs.
When testing for specific values of a floating-point number, it’s wise to
test for a range rather than a single value. For example, the condition
d == 0.0
could be replaced by Math.abs(d) < 0.000001
.
As you know, the int
and long
types have limited bits for
storage. If an arithmetic operation pushes the value of an int
variable larger than Integer.MAX_VALUE
, the variable will come full
circle and become a negative number, usually with a large magnitude. The
converse happens when a variable is pushed lower than the smallest value
it can hold. These situations are called overflow and underflow,
respectively, and Java throws no exceptions when they occur. Programmers
who deal with large magnitude values in int
or long
types get used
to underflow and overflow, and when unexpected values are output by
their programs, they’re usually quick to pin down the problem variable.
Overflow and underflow can cause more surprising bugs when programmers
forget the sharply limited range of values for byte
and char
types. For
example, a curious beginner programmer might want to print out a table
of all of the possible values for char
. Perhaps the programmer has
forgotten the range of values a char
can take and is stumped when
the following loop does not terminate.
for(char letter = '\0'; letter < 100000; letter++)
System.out.print(letter);
Each time letter
reaches Character.MAX_VALUE
which is '\uFFFF'
or
65535
as a numerical value, the next increment pushes its value back
to 0. These kinds of errors with byte
and char
values are most
common when they’re being used as numbers. Some
possibilities are cryptography, low level file operations, and manipulation
of multimedia data. The best solution is care and attention. It can help
to store the values in variables with more bits such as int
or long
values, but care must still be taken to ensure that these values are
within the appropriate range before storing them back into variables
with a smaller number of bits.
For example, color values in many image formats are stored as red, green
blue values with a byte
used for each of the three colors. In this
system, the darkest color, black, is represented as (0,0,0)
, zero
values for each of the three byte
values. At the same time, the lightest
color, white, is represented conceptually as (255,255,255)
. In
principle, we can perform a simple filter to increase contrast and
lightness by doubling all the pixel values. Given red, green, and
blue color values stored in three byte
variables called red
,
green
, and blue
, a naive implementation of this filter might be as
follows.
red *= 2;
green *= 2;
blue *= 2;
In Java, this code would not function correctly. The first problem is that, even
though image standards are written with color values between 0 and 255,
Java byte
values are signed. The web standard for the color purple
has red, green, and blue values of (128,0,128)
. Since Java byte
values are signed, printing the byte
values for each component of
purple directly will actually print (-128,0,-128)
. Multiplying the
green value by 2 still gives 0. However, multiplying -128 by 2 as a
byte
value is -256 which underflows back to 0. Thus, “brightening”
purple actually turns it into (0,0,0)
, black. Properly applying the
filter to a byte
requires a conversion to the int
type, masking out
the sign bit, scaling by 2, capping the values at 255, and then casting
back into a byte
. Despite the complicated description, the code is not
too unwieldy.
// Bitwise AND automatically upcasts to int
red = (byte)Math.min(255, 2*(red & 0xFF));
green = (byte)Math.min(255, 2*(green & 0xFF));
blue = (byte)Math.min(255, 2*(blue & 0xFF));
The previous example about scaling color component values shows one of the
dangers of casting. Someone can easily forget that the implicit cast to
convert a byte
to an int
always uses a signed conversion. Likewise, the
explicit cast needed to store an int
into a byte
will cheerfully convert
any arbitrarily large int
into a byte
, even though the final value might
not be expected by the programmer.
Many other casting errors commonly crop up. The most classic example might be muddling floating-point and integer types.
int x = 5;
int y = 3;
double value = 2.0*(x/y);
Above, it’s easy for a programmer to forget that the division of x
and y
is integer division. After all, the 2.0
is right there,
causing an implicit cast to double
. Of course, this cast happens after
the division, and the answer stored into value
is 2.0
and not the
3.3333333333333335
that the programmer might have expected.
Newer programmers sometimes forget that an explicit cast from a floating-point type to an integer type always uses truncation, never rounding.
int three = (int)2.99999;
This assignment will always store 2
into three
. The Math.round()
method or some other additional step is needed to perform rounding.
Casting errors are not limited to primitive data types. Object casting will be discussed at length in Chapter 17. The biggest danger there is an incorrect explicit upcast.
Fruit snack = new ChiliPepper();
Apple apple = (Apple)snack;
In a botanical sense, a chili pepper is indeed a fruit and its parallel
Java class is apparently a child of the Fruit
class. For some reason,
the programmer thought that the only Fruit
that would be pointed at by
a snack
reference would be of type Apple
. Instead of a mouth on
fire, the programmer gets a ClassCastException
. This two line example
is so simple that it should never come up in serious programming. A much
more common example is an array or linked-list whose contents have a type
that’s the parent class of the item you generally expect to be stored. If a large
team is working on a body of code with such a list, half of the
team might expect the list to contain only Apple
objects while the
other expected only ChiliPepper
objects. The use of generics,
discussed in Chapter 18,
can reduce the number of casting errors of this kind, but some applications require a
list to hold many different types with a common superclass. In those
cases, some amount of explicit (and therefore dangerous) casting will
usually be necessary when retrieving objects.
Loops give Java much of its expressive power, which includes the power to express incorrect code. Here are examples of a few of the most common loop errors.
Computer scientists often use zero-based counting. This departure from “normal” practices is just one source of loops that iterate one time more or less than they should. If you want to iterate n times, a good rule of thumb is to start at 0 and to go up to but not including n. Alternatively, if you have a reason not to be zero-based, you can start at 1 and go up to and including n.
for(int i = 1; i < 50; i++)
System.out.println("Question " + i + ".");
Perhaps you want to make a template for an exam. Instead of being zero-based, you start at 1 because most exams don’t have a Question 0. Unfortunately, you’ve gotten so used to using strictly less than for your ending condition that you forget to change it and only get 49 questions printed out. If your purpose was making an exam, you could catch your mistake and move on. If you’re writing a program that dispenses a quantity of heart medication into a patient’s IV in a hospital, one iteration too few or too many could cause the patient to get too little of the drug to make a difference or too much of the drug to be safe.
Input is another tricky area when it comes to being off by one.
int i = 0;
double sum = 0;
int count = 0;
Scanner scanner = new Scanner(System.in);
while(i >= 0) {
sum += i;
System.out.print("Enter an integer (negative to quit): ");
i = scanner.nextInt();
count++;
}
System.out.println("Average: " + (sum / count));
This fragment of code appears to be a perfectly innocent loop that finds
the average of the numbers entered by a user. The loop uses a sentinel
value so that the user simply enters a negative number when all the
numbers have been entered. The value of sum
is updated before the user
enters a value; thus, the harmless 0
from the declaration of i
is
included but the final negative number entered to leave the loop is not.
Unfortunately, the value of count
is incremented for every turn of the
loop, even the extra one for the negative number. To combat this
problem, an if
statement could be used inside of the loop or count
could simply be initialized to -1
. The mistake is a simple one, but it
doesn’t jump out at you unless you trace a few executions. What’s most
insidious is that the error is going to be small, especially for large
sets of input numbers. Catching this kind of bug will be discussed more
throughly in the second half of this chapter which deals with testing.
Infinite loops come in many different flavors, from the char
overflow
example earlier to traversing a linked-list which has a cycle in it.
Many infinite loops are caused by simple typographical errors. Perhaps
the most classic is:
int i = 1;
while(i <= 100);
{
System.out.println(i);
i++;
}
It’s usually a beginning programmer who leaves a semicolon at the end of
the while
header, but even veterans can get overly enthusiastic
about semicolons. Often a programmer confronted with such a bug (which
causes no output, in this case) will scour the body of the loop without
carefully scrutinizing the condition. An extra semicolon at the end of
a for
loop header will usually cause an error but will usually not
cause an infinite loop.
public double average(int[] array) {
double sum = 0;
int count = 0;
for(int i = 0; i < 100; i++) {
sum += array[i];
count++;
if(i == 0)
i--;
}
return sum / count;
}
This example might be too absurd to appear in a
textbook, but nearly everyone has written worse code while learning to
program. We could suppose that this method is meant to average the
values in an array, but for some reason, zero valued entries are not to
be counted. The student probably meant to have the following if
statement.
if(array[i] == 0)
count--;
Those two small changes turn the method into a working but slightly
inelegant solution. When debugging remember that index variables in
for
loops can get changed in the body of the loop and change the
expected behavior. Generally, it’s a bad idea to change the value of an
index variable anywhere other than the header of a for
loop, but there
are times when doing so gives the cleanest solution.
Many loop errors are caused by a bad header. Getting the inequality backward or switching increment and decrement will usually make a loop that runs a very long time or not at all. We’ll see the second possibility just a little later.
for(i = 10; i > 0; i++)
System.out.println(i + "!");
System.out.println("Blast-off!");
In this case, the programmer clearly wanted to count down from 10 to
1, but after so much incrementing, he or she forgot to make i
decrement. As a result, the value of i
increases for a very long (but
not infinite) time, until it overflows.
On the other end of the spectrum, a bad condition can make a loop
execute zero times on for
and while
loops. For some input, doing so
might be intended behavior. In other cases, no input will ever cause the
loop to execute.
int i = 0;
double sum = 0;
int count = -1;
Scanner scanner = new Scanner(System.in);
while(i > 0) {
sum += i;
System.out.print("Enter an integer (negative to quit): ");
i = scanner.nextInt();
count++;
}
System.out.println("Average: " + (sum / count));
We’ve returned to our earlier example of averaging a set of
numbers input by the user. This time we’ve initialized count
to be -1
to avoid the off-by-one error, but we’ve also changed the inequality
of the while
loop from greater than or equal to strictly greater. As a
consequence, the loop is never entered because 0, the initial
value of i
, is too small.
public static boolean isPrime(int n) {
for(int i = 1; i < n; i++) {
if(n % i == 0)
return false;
}
return true;
}
Here’s a simple method intended to test the number n
for primality.
Unfortunately, the programmer started the index i
at 1 instead of 2.
As a consequence, this loop will only run once before finding that any
number is divisible by 1. True, this is not a loop that executes zero
times, but only once is still just as wrong.
public static boolean isPrime(int n) {
for(int i = 2; i < n; i++) {
if(n % i == 0)
return false;
else
return true;
}
}
This example is similar code trying to solve the same problem.
Again, the loop only runs once because the programmer forgot that
finding a single case when a number is not evenly divisible by another
number does not make it prime: a large range of possible factors must be
checked before we can be sure. Many, many beginning programmers make
this mistake when asked to solve this problem. Perhaps some insight
about the nature of bugs can be gained from this example. By the time a
student writes a program of this kind, he or she should have a fair idea
of how for
loops and if
statements work. Likewise, the student will
have a fair understanding of the notion of primality. Yet in the
process of combining the ideas together, it’s easy to get sloppy and
write incorrect code that gives some semblance of being correct.
Equivalence is tricky in Java. Very inexperienced programmers confuse
the =
operator with the ==
operator, but using the ==
operator to
test for equivalence between two references causes more (and subtler)
problems. Comparing two references with the ==
operator will evaluate
to true
if and only if the two references point at the exact same
object.
String string1 = new String("Test");
String string2 = new String("Test");
if(string1 == string2)
System.out.println("Identical");
else
System.out.println("Different");
Because these two String
references point to two different String
objects, which happen to have identical contents, the ==
returns
false
, and the output is Different
. With String
objects this matter
is further confused by a Java optimization called String
pooling.
String string1 = "Test";
String string2 = "Test";
if(string1 == string2)
System.out.println("Identical");
else
System.out.println("Different");
Because Java keeps a pool of existing String
values, only one copy of
"Test"
is in the pool, and both string1
and string2
point to it.
Thus, this second fragment of code prints Identical
. Because of
String
pooling, programmers can write code which can work in some
situations and fail in others, if it depends on the ==
operator.
For String
objects as well as every other reference type, it’s
almost always the case that the equals()
method should be used to test
for comparison instead of the ==
operator. There are a few instances
when it’s necessary to know if two references really and truly do refer
to the same location in memory, but these instances are a tiny
minority.
That said, the equals()
method is not bullet-proof. With String
objects and most of the rest of the Java API, you can expect good
behavior from the equals()
method. However, if you create your own
class, you’re expected to implement the equals()
method. By default,
the equals()
method inherited from Object
only does an equality test
using ==
.
Properly implementing the equals()
method takes care and thought. If
your class contains references to other custom classes, you must be
certain that they also properly implement their own equals()
methods.
Likewise, to conform to Java standards, a custom equals()
method
implies that you have also implemented a custom hashCode()
method so
that objects that are equivalent with equals()
give the same hash
value. It seems nit-picky to mention this issue, but many real-world
applications depend on the efficient and correct operation of hash
tables. Hash tables are data structures used to store and
retrieve a key and an associated value. We’ll discuss them briefly
in Example 18.4.
16.3.5. Array errors
Any time you have a large collection of data, there are always opportunities for bugs. With catastrophic array bugs, Java usually gives clear exceptions that point you to the line number. Once the exception is thrown, the bug should be obvious. The biggest difficulties arise when some unusual course of events is responsible for the bug cropping up and you have to reconstruct what it is.
We have all experienced an ArrayIndexOutOfBoundsException
. Either a
little carelessness with our indexes or a mistake about the size of the
array can lead us to try to access an index that isn’t in the array.
In the C language, a negative index is sometimes a legal location, but it
never is in Java. Although errors involving negative indexes sometimes occur
in code, a more common error is accessing an index slightly larger than the
bounds of an array, particularly with a loop.
int[] array = new int[100];
for(int i = 0; i <= 100; i++)
array[i] = i;
In this example, the last iteration of the loop will access index 100
when array
only goes up to index 99
.
The causes for going out of bounds can be more subtle. We can imagine an
array of linked lists, perhaps for storing English words in a dictionary. If we
want to select a list based on the first letter of the word, we could use
an array of length 26. Consider the following helper method used to add a
new String
to the correct list.
public void add(String word) {
int index = word.toLowerCase().charAt(0) - 'a';
lists[index].add(word);
}
We can assume that the add()
method for a given linked list works
properly, but we might have caused other problems already. For one thing,
we assumed that word
began with either an upper- or lowercase letter,
corresponding to our array locations 0 through 25. We’re depending on
other code to check the input and throw out String
values
like "$1"
or "-isms"
, which would map to indexes outside of the 0 to 25
range. Incidentally, we’re also assuming that word
has at least one character
in it. Even if we expect the input to the method to be error free, the addition
of error checking is rarely a bad idea.
Another simple mistake that can occur with arrays is failing to
initialize an object array. With a primitive data type like int
,
creating an array with 1,000 elements automatically allocates enough
space to hold those elements and even initializes each one to a default
value, 0
in the case of an int
. With an object data type, however,
each element of the array is a reference to null
until it’s
initialized.
Hippopotamus[] hippos = new Hippopotamus[15];
hippos[3].feed();
This example causes a NullPointerException
. New programmers are often
confused by this error because they expect the exception to mention the array
or its indexes. For more experienced programmers, this kind of mistake is
more of a forehead-slapping oversight than a mind-numbing puzzler that’ll take
hours to debug. It’s probably just a matter of instantiating each element in
the array before you try to feed those hungry, hungry hippos.
Hippopotamus[] hippos = new Hippopotamus[15];
for(int i = 0; i < hippos.length; i++)
hippos[i] = new Hippopotamus();
hippos[3].feed();
16.3.6. Scope errors
We don’t have variables in real life, and as a consequence, our intuition about them is sometimes wrong. Which variable you’re accessing at any given time can appear obvious, even if it really isn’t.
Java allows variables in different scopes to be declared with the same identifier. If the scopes are two separate methods, then they’ll never interfere with each other. However, if one scope encloses another, the inner variable will shadow or hide the outer variable.
public class Shadow {
int darkness = 10;
public void deepen(int darkness) {
darkness += darkness;
if(darkness > 100)
darkness = 100;
}
public int getDarkness() { return darkness; }
}
In this example, the field darkness
is being shadowed by the local
variable darkness
in the deepen()
method. It appears that the
programmer wanted to increase the field darkness
by the amount passed
into the parameter darkness
and failed to notice that both variables
have the same name. As a consequence, the parameter darkness
will
double itself and then never be used again while the field darkness
will never increase. This kind of bug could go uncaught for a long while
until a programmer notices that the Shadow
object isn’t increasing in
darkness no matter how many times it’s told to.
This mistake is also common in constructors, since it’s
reasonable to give a certain parameter a name similar to the field it’s
about to initialize. Some programmers explicitly prefix all fields with
this
even though it’s often redundant. Three additions of this
will
fix the problem in the preceding example.
public void deepen(int darkness) {
this.darkness += darkness;
if(this.darkness > 100)
this.darkness = 100;
}
In Java, scope is also defined in terms of classes and their parent classes. A parent class variable can be shadowed by a child class variable of the same name.
public class Bodybuilder {
public int strength = 8;
public boolean isStrongEnough(int strengthNeeded) {
return strength >= strengthNeeded;
}
public void setStrength(int value) { strength = value; }
}
public class BraggingBodybuilder extends Bodybuilder {
public int strength = 10;
public void brag() {
System.out.println("My strength is " + strength + "!");
}
}
This example looks like a simple case of inheritance, but whoever wrote
the BraggingBodybuilder
class mistakenly included the
field strength
again. As a consequence, any BraggingBodybuilder
will
always brag that his or her strength is 10, even when code sets his or
her strength to other values. When strength is tested, it’ll use the
strength
field from the superclass Bodybuilder
which is set by the
setStrength()
method. When classes have large numbers of fields,
making such a mistake becomes easier.
Dynamic and static binding complicate this scope problem further. The fragment of code below using the class definitions from above highlights these complications.
Bodybuilder builder = new BraggingBodybuilder();
builder.strength = 15; (1)
BraggingBodybuilder bragger = (BraggingBodybuilder)builder;
bragger.brag();
bragger.strength = 20; (2)
bragger.brag();
1 | Because fields are statically bound to the class of the
object, the strength field for Bodybuilder will be set to 15. |
2 | However, the strength field for BraggingBodybuilder
will be set to 20 here. |
Thus, the first call to brag()
will print out My strength is 10!
, but the
second call will print out My strength is 20!
. Note that some of the
confusion in this example is possible only because the field strength
is
public
. If the field was private
and only changed through methods, at
least there would no longer be two ways to set the strength
field with
two different outcomes.
The final category of scope error we’ll talk about occurs when using methods because of confusion between passing by reference and passing by value. Every variable in Java is passed by value. However, when that value is itself a reference, it’s possible to change the values that it references.
public void increaseMagnitude(int number) {
number *= 10;
}
A novice Java programmer might write a method like the above, expecting
the value of number
to increase by 10 in the calling code. Some
languages like Perl use call by reference as default. Other languages
like C++ and C# allow the user to mark certain parameters as call by
reference. Programmers comfortable with such languages might be confused
about the workings of Java.
On the other hand, becoming used to the pass-by-value style of Java can cause other errors.
public void increaseMagnitude(int[] numbers) {
numbers[0] *= 10;
}
In this contrasting example, the 0 index element of numbers
is increased
by a factor of 10. Unlike the previous code, the increase in the value
of that element will affect the array passed in by the calling code. The
values in the array are shared by the increaseMagnitude()
method and
the calling code. As you can see, the variable numbers
isn’t changing.
Reassigning the array reference to a difference array reference would have
no affect, but we’re changing an element in the array, not the array
reference. The same phenomenon can occur with the fields of objects whose
references are passed into a method.
16.3.7. Null pointer errors
Null pointer errors usually raise a NullPointerException
in Java. This
category of errors is something of a catch-all that could happen for
many different reasons, some of which have already been mentioned.
A NullPointerException
could be raised because the elements of an object
array haven’t been initialized. Scope problems could cause a reference to
be null
if the programmer was mistakenly updating another reference,
leaving the reference in question uninitialized.
Though they are common, it’s difficult to give a blanket explanation for
why most null pointer errors happen. Usually there’s some fundamental
error in program logic. Linked lists and tree structures that rely on
null
references to mark the end of a list or an empty child node are
especially susceptible to these errors.
One significant source of errors is careless usage of method parameters.
A programmer might pass in objects that don’t conform to expectations
or even null
references instead of objects. Well written
methods, particularly library calls, should be designed to throw an
appropriate exception when this happens. Poorly designed code might
blindly use a null
reference without checking it first, causing a
NullPointerException
.
16.4. Concurrency: Parallel bugs
We’ll discuss parallel bugs briefly here because we’ve already spoken in depth about the dangers of parallel programming in Chapter 14. Beyond deadlocks and livelocks, a key difficulty with parallel bugs is that they can make the appearance of sequential bugs nondeterministic and unpredictable.
16.4.1. Race conditions
A race condition describes the situation when the output of a program is dependent on the timing of the execution of two or more threads. Because of the complexity of the JVM and the OS and the fact that many other processes might be running and interacting, it’s usually impossible to determine how two threads will be scheduled. As a consequence, if the output of the program depends on unpredictable timing, the output will also be unpredictable.
In Java, the way that race conditions often impact the program is through some variable shared between multiple threads. When the schedule of threads becomes unpredictable, the changes made to this variable can come out of sequence, and its value becomes unpredictable. Incorrect output means that your program has a bug, but the most frustrating aspect of race conditions is that they’re nondeterministic. Your program could sometimes have the right answer and sometimes not. Your program could always have the wrong answer but not always the same one. The truly insidious issue with race conditions is that they’ll usually cause errors only a tiny percentage of the time. Thus, rigorous testing such as we’ll discuss in the second half of this chapter is necessary to show that a race condition is occurring.
16.4.2. Deadlocks and livelocks
Both deadlocks and livelocks describe situations in which some part of your program will stop making progress because of thread interaction. In the case of deadlock, there will be a circular wait in which thread A is waiting for thread B which is waiting, directly or indirectly, on thread A. In the case of livelock, some repetitive pattern of waiting for a condition that will never be satisfied is still going on, but the threads continue to use CPU time and aren’t simply waiting.
If your program reaches a deadlock state, it won’t terminate. If
threads updating a GUI become deadlocked, your window might freeze.
Typically, deadlocks are nondeterministic and occur only some of the
time. Like all race conditions, they can be difficult to detect and
duplicate. In fact, Thread.stop()
, Thread.suspend()
, and
Thread.resume()
, three seemingly useful and fundamental methods that
were originally part of the Java Thread
class, have been deprecated
because they are deadlock prone.
16.4.3. Sequential execution
One bug that isn’t even a bug in non-parallel code is sequential execution. This situation arises when, usually due to overuse of synchronization tools, parallel code runs sequentially. Each segment of code, instead of running in parallel, is forced to wait for another to complete. A certain amount of serial execution is necessary to maintain program correctness and avoid race conditions, but Amdahl’s Law gives a rigid, mathematical characterization of how easily speedup can be lost if serial execution makes up large portions of program execution. Because setting up threads and using other concurrency tools has some overheard, a parallel program executing sequentially often runs more slowly than a completely sequential version.
Since programs are usually parallelized for the sake of speedup, it’s useful to time sections of programs to see how well you’ve parallelized them. Sequential execution due to synchronization tools is only one of the many problems that can cause slow execution. The threads might be competing for a limited resource such as an I/O device or might be fighting over a small section of memory, causing cache misses. Tuning applications for maximum performance requires an expert understanding of the concurrency issues within software as well as the underlying OS and hardware characteristics. For now, it’s enough to be aware of the risk of sequential execution and to be as careful as possible when applying locks and other synchronization tools.
16.5. Finding and avoiding bugs
What would you do if you wanted to design software for a system that administers a dose of radiation to a specific location on a patient to help treat them for cancer? Depending on the specification of the problem, you might need to control various voltage sources, read data from sensors, and create a command-line interface or a GUI. With a well-designed specification, you could probably apply your knowledge of loops and control structures to an API and develop a software solution that met requirements, provided that an appropriate hardware platform existed.
But how would you know that it worked? Sure, you could run a series of tests, but how many tests would it take for you to be convinced that it worked perfectly? What if your grade was dependent on it working without a single error? Or your job? Or your life?
You’ve probably already faced the stress of trying to get a program to work as well as possible for the sake of your grade. It’s not such a far cry to imagine your job being on the line if you make a serious mistake as a professional programmer. But what about your life? Perhaps you’ll never put your own life into the hands of code you write, but odds are that you’ve already put your life into the hands of someone else’s code. Software controls airplanes, automobiles, medical equipment, and countless other applications where a bug in the code could result in loss of human life.
Sadly, there have already been cases when such bugs have surfaced with deadly consequences. One of the most famous examples of the dangers of badly written software is the Therac-25. The Therac-25 was a machine designed to deliver therapeutic radiation for medical purposes. Between 1985 and 1987, use of the Therac-25 caused at least six incidents of massive radiation overdoses, leading to at least three deaths.
Like most failures of this magnitude, there was more than a single cause behind the Therac-25 tragedies. For one thing, the machines did give an error code. However, the user manual did not explain the error code, and the technicians were not trained to deal with the errors. Even when patients complained about pain caused by the machines, the technicians and even the manufacturers of the Therac-25 were confident that the machine was operating correctly because neither of the previous models, the Therac-6 and the Therac-20, had suffered any problems. Overconfidence has played a significant role in many of the worst systems failures, including the devastating Chernobyl disaster.
Ignoring the human errors, a number of software errors were also responsible for the Therac-25 overdoses. The overdoses occurred when technicians made incorrect keystrokes giving confusing instructions to the Therac-25 about which mode of operation it should be in. In this situation, the machine would operate with a high-power beam but without the beam spreader that was necessary for its safe operation. The designers ignored the possibility that this series of keystrokes would happen. Also, a race condition was involved in this bug since it depended on one task that set up the equipment and another that received input from the technician. This race condition wasn’t caught during testing because only technicians with long practice could work fast enough to cause the bug. Finally, a counter was incremented for use as a flag variable, but arithmetic overflow occasionally caused this flag to have the wrong value.
In the remaining half of this chapter, we’ll discuss a number of testing methodologies and design strategies to minimize errors in software.
16.6. Concepts: Design, implementation, and testing
Unfortunately, there’s no foolproof way to design software. There are many researchers who work to design new languages and new development tools to limit certain kinds of mistakes, but it’s impossible to design a language as powerful as Python or Java which will also prevent all software bugs. A consequence of the halting problem, a fundamental concept in the theory of computation, is that there’s no way to design a test that will detect all potential infinite loops (or infinite recursion) for all programs.
With careful design, implementation, and testing, most errors can be reduced almost to nothing. In the following subsections, we’ll discuss these three aspects of programming and how you can apply them to writing better programs.
16.6.1. Design
We’ve remarked in the past that good design pays off ten-fold in implementation, and that payoff continues to increase by factors of ten as you move on to testing and eventually deployment.
One of the first design decisions you might have to make is choice of
language. Some languages are better designed for certain tasks than
others. For example, languages like Ada have been carefully designed to
minimize programming mistakes such as mis-matched else
blocks. Many
functional languages like ML are designed so that memory errors such as
a NullPointerException
are impossible. Even Java has taken clear steps
to avoid some of the errors possible, such as bus errors, in C and similar
languages that allow pointer arithmetic. However, many other factors such
as portability, compatibility, and speed will affect your language
choice.
If you’re working in the software development industry, you might be given a specification from your client or your supervisors. As you design the software needed to meet the specification, you might use UML diagrams to map out the classes and interactions you plan to implement in your program.
There are many questions you might ask yourself as you design your solution. Will your solution be compatible with the system and future changes made to the system? Is it easy to add features to your solution? Does your solution deal gracefully with mistakes in user input or external hardware and software failures? Is your code easy to maintain, particularly by future programmers who were not involved in its initial development? Are the components of the system modular? Can they be worked on, tested, and upgraded independently? Are the components of your system designed well enough to be reused for other applications? Are the elements of your system secure from malicious attacks? Finally, is it easy for the user to work with your software?
Each one of these questions is related to a separate sub-field in software engineering. It might be impossible to address them all completely, but different applications will have different priorities. One method for OO software engineering uses design patterns. The idea behind design patterns is that most classes share some common design principles with a large category of other classes. By naming and recognizing each category, you can apply the same rules to designing new classes from a category you’re already familiar with. Each category is called a design pattern. Java uses design patterns extensively in its API. Describing design patterns in greater depth is beyond the scope of this book, but you might want to consult the Gang of Four’s popular book Design Patterns.
Another important idea in design is design by contract. Although this is also a rich, complex area of software engineering, the idea can be applied to methods in a straightforward way. For each method, you have a formal explanation of what its input should be, what its output should be, and what else can be changed in the process. For some languages and some segments of code, it’s possible to prove that a given method does exactly what it’s supposed to do. Nevertheless, Donald Knuth, a giant in computer science, is famous for having said, "Beware of bugs in the above code; I have only proved it correct, not tried it."
16.6.2. Implementation
Once you have gotten your design to the implementation phase, there are a number of other techniques you can use to minimize errors. One interesting technique is pair programming, in which two programmers sit at a single computer and work together. Ideally, the programmer who is currently typing, often called the driver, is thinking about the immediate problems posed by the next few lines of code while the other programmer, often called the navigator, is thinking about the larger context of the program and watching for errors. Two sets of eyes are always beneficial when looking at something as detailed and confusing as a computer program.
In keeping with the theme of having more than one set of eyes looking at a program, it’s also useful to have the individuals who test the software be independent from those who develop it. By keeping the testers separate, they’re not infected by the assumptions and biases that the developers made while writing the software. Some communication between the two groups is necessary, but there’s value in black-box testing, which we’ll explain in the next subsection.
Another piece of general advice is to rely on standard libraries as much as possible. Reinventing your own libraries is partly a waste of time and partly dangerous because your own libraries haven’t undergone as much testing as the standard ones. Likewise, it makes your code less portable. Some expert developers might need to write special libraries for speed or memory efficiency, but they’re the exception, not the rule.
There are a number of Java specific implementation guidelines. People have written entire books about good software engineering in Java, and so we’ll only give a few obvious pointers.
Although it’s tempting to do so when working under time pressure, never
write empty exception handlers. Doing so swallows exceptions blindly,
giving the user no information about the errors in his or her program. By
the same token, always make your exception handlers as narrow as
possible. Simply putting catch(Exception e)
at the end of any
try
-block has one of two possible outcomes: In one case your handler
is vague and the user is informed that a general error of some kind has
occurred. In the other your handler is more specific than it has a right
to be. You might have assumed that a file I/O error was likely to
occur and always report that failure. Instead, an
ArrayOutOfBoundsException
could happen and be mistakenly reported as a
file I/O problem.
You should test the input to any public methods you write and throw a pre-determined exception if the input is invalid. Never use assertions to test input to public methods. In fact, you should never depend on assertions to catch errors since they must be turned on in the JVM to have effect. Assertions are great for debugging code before it’s released but have little or no value in the field.
16.6.3. Testing
Once you’ve designed and implemented your program (or ideally throughout the process of implementation), you should test it to see if it behaves as expected and required. The most common form of software testing done by students is a form of smoke test. A smoke test is a basic test of functionality. Such a test should simply run through the major features of a program and verify that they seem to work under ordinary circumstances. Often a student will barely finish the program before the deadline and be unable to perform anything but the most basic tests.
Smoke tests are useful because it’s pointless to test the finer details of a system that’s clearly broken, but the software engineering industry uses many other kinds of testing to ensure that a given piece of software meets its specification. We’ll briefly cover three broad areas of testing: black-box testing, white-box testing, and regression testing.
Black-box testing
Black-box testing assumes that the tester knows nothing about the internal mechanisms of the software he or she is testing. The software is viewed as a “black box” with inputs and outputs but otherwise unknown internals. The tester chooses some subset of the possible inputs and tests to see if the output matches the specification.
For simple programs with very little input, it might be possible to test all possible input values, but doing so is impractical for most programs. A short list of techniques for determining the appropriate set of input values for black-box testing follows.
- Equivalence Partitioning
-
The idea behind equivalence partitioning is that large ranges of data might be functionally equivalent from the point of view of causing errors. If a tester can run a test for one element from a range of data, then the entire range can be tested quickly. To perform this kind of testing, the tester must partition data into ranges that function differently. The partition created is usually not really a partition in the mathematical sense as the sub-domains are overlapping. For this reason equivalence partitioning is also referred to as subdomain testing.
For example, a program controlling the temperature of the water in an aquarium might have legal input ranges between 32 °F and 212 °F. However, if the program warms the water when it’s below 75 °F and cools it when it’s above 90 °F, then values below 0, values from 0 to 74, values from 75 to 90, values from 91 to 212, and values above 212 all constitute different partitions. - Boundary Value Analysis
-
Once inputs have been partitioned into equivalent ranges, testers can focus on values which are near the boundaries of those ranges. For example, an input containing a person’s age might be allowed to range between 0 and 150. The values -1, 0, 1, 149, 150, and 151 are good candidates for input from the perspective of boundary value analysis. As with equivalence partitioning, boundary value analysis is useful not only for the boundaries between valid and invalid data but also for the boundaries between any input ranges with different program behavior such as the boundaries separating the five ranges of values for the aquarium thermostat program described above.
- All-Pairs Testing
-
Most software bugs are triggered by a single piece of input. Some harder to discover bugs require two separate pieces of input to have specific values at the same time before they manifest. With each increase in the number of different inputs that must each have specific values at the same time to cause a bug, the bug becomes increasingly difficult to detect but also increasingly unlikely to exist. It might be possible to test all possible values for a given input but impossible to test all possible values for all inputs at the same time. All-pairs testing is a compromise between these two extremes that tests all possible pairs of inputs.
- Fuzz Testing
-
The concept behind fuzz testing is to use large amounts of invalid, unlikely, or random data as input to a program. Although this kind of testing is used only to test the reliability and robustness of a program receiving unexpected input, it has a number of advantages. One significant advantage of fuzz testing is that it’s quick and easy to design test cases. Another is that it makes no assumptions about the program behavior, catching errors that might never occur to a human being. If fuzz testing is automated, it can also be used for stress testing, in which the program’s ability to process a large amount of data quickly or while remaining responsive is tested.
White-box testing
The philosophy of white-box testing is the opposite of black-box testing. When using white-box testing techniques, the tester has access to program internals. The tester should employ techniques to test every possible path that execution can take through the code. Traversing a particular path of execution through a program is called exercising that path.
In order to exercise every possible path, it’s necessary to force each conditional statement to be true on some path and false on another. Some combinations of true and false might be impossible, but ignoring this fact, a program with n independent conditionals would require 2n runs to test them all. Because of the large number of possible execution paths, white-box testing generally tries to maximize coverage over metrics that are not quite so demanding.
Method coverage is the percentage of methods that are called by test cases at least once. Ideally, this number is 100%. Statement coverage is the percentage of statements that are executed by test cases. Again, this number should be as close to 100% as possible. Branch coverage is the percentage of conditionals that have been executed on both their true and false branches. Getting total coverage here is difficult, but good testing can come close.
As with black-box testing, equivalence partitioning and boundary value methods can be used to reduce the total number of test cases. Also, it’s important to test those parts of your programs reached only in error conditions in addition to normal operation.
Regression testing
Regression testing is a form of testing that’s not often necessary for student code, which is usually focused on small projects. The motivating idea behind regression testing is that, in the act of fixing a bug or adding a feature, existing code can be broken. Thus, even after a system has been thoroughly tested, small changes or additions require the entire system to be retested. As the size of a program grows, the chance of unintended consequences increases, along with the value of performing regression testing.
Regression testing can incorporate both black- and white-box testing. Doing regression testing could simply mean running all the existing tests over after every major change. At the very minimum, each time a test uncovers a bug, that test should be added to the test suite used after each build of the program. The use of regression testing also implies that regular testing is being done on your code. Regular testing gives developers the opportunity to track changes in other aspects of their program such as memory usage, running time, responsiveness, and other non-functional issues.
16.7. Syntax: Java testing tools
One open-source tool for testing Java is called JUnit. There are other testing tools for Java, and there’s a wide array of tools for testing software in virtually any language. We cover JUnit here because it’s widely accepted as a standard Java testing tool and because it’s open source.
16.7.1. JUnit testing
JUnit testing is used for unit testing Java. Unit testing is the process of testing separate software components that will eventually work together. By testing them individually, debugging can be done before interactions between different components make it more difficult to find the underlying bugs. After unit testing comes integration testing to test how the components work together. Finally, system testing is the testing of the complete, integrated system against its specifications.
Annotations
Our coverage of JUnit testing is based on JUnit 5. This version of JUnit has simple syntax for creating JUnit tests compared to JUnit 3 and earlier, but it also relies on annotations. An annotation is additional information written into Java code that affects how the compiler or run-time system treats the code. They are like comments, but they can affect code execution or compilation, usually indirectly. Applying an annotation to a method is called decorating. A class, a method, a variable, a package, or even an individual method parameter can be decorated.
There are several annotations built into Java. Here, we consider three: @Deprecated
,
@Override
, and @SuppressWarnings
. If a method is decorated with
@Deprecated
, it’s deprecated and included only for backward compatibility.
The compiler will give a warning if you call deprecated code such as the following.
@Deprecated
public void oldMethod() {
...
}
Many methods in the extensive Java API are deprecated, like
Thread.suspend()
, due to its inherent deadlock risk. As of Java 5 when
annotations were introduced, existing deprecated methods were decorated with
@Deprecated
. Before that, the only way to know that a method was deprecated
was by reading the documentation. The @Override
annotation marks a method that’s
overriding a parent class method, causing a compiler error if the method
isn’t correctly overriding some parent class method. The @SuppressWarnings
annotation allows certain warning messages to be suppressed, like using
deprecated code if you really have to.
Basic JUnit syntax
First of all, JUnit is not a part of the standard Java API. To use it, you should download the latest jar file from the JUnit site and add the path to that jar file to your class path. Because JUnit is so universal, Eclipse, IntelliJ, and many other IDEs provide a way to add the JUnit library to a project without the need to download the jar file separately.
To access the JUnit facilities in your code, you need the following import.
Note that this import is different from JUnit 4 and earlier versions which
used classes from the org.junit
package.
import org.junit.jupiter.api.*;
Then, you need to set up a testing class just like you would any other
class. The key difference is that each method in the testing class is
designed to test some functionality of a code component. For example,
let’s imagine that we want to test functionality within the Java
Math
class such as the ceil()
, pow()
, and sin()
methods.
To do so, we create a class called MathTest
with three methods inside
of it called ceilTest()
, powTest()
, and sinTest()
. Note that it’s a
common JUnit convention to end the names of the testing methods with “Test.”
We’ll use each method to test the functionality of the three methods
that, respectively, have matching names. Although ending with “Test” is
a convention, there’s no requirement to name these methods any particular
way. Tests in JUnit don’t have to test single method calls. They could
test any functional aspect of an object or class. Nevertheless, for
documentation reasons it’s wise to give the test methods names that
reflect what’s being tested.
Where do annotations come in? The header for the ceilTest()
method
should be as follows.
@Test
public void ceilTest()
The only thing necessary to use a method for a JUnit test is to annotate
it with @Test
. It’s also necessary to make any function used for
testing public
with a void
return type and no parameters. Otherwise,
the JUnit framework will crash when you try to run the tests. Each
method with a @Test
annotation is run once by JUnit, but JUnit can’t
supply any arguments to them. They should be self-contained tests
without any outside input.
The exception to this rule is that you can perform some set up for the
tests and then some clean up afterward. Any method decorated with
@BeforeEach
will be run before every test, and any method decorated with
@AfterEach
will be run after every test. If you have some set up or
clean up that’s expensive to run, you can use the annotations
@BeforeAll
or @AfterAll
to decorate a static method that’s run
once before or after all the tests.
So far we have talked about the major aspects of writing a JUnit test class except for the test itself. How does the JUnit test report a success or a failure to the tester? As you would expect in Java, we use the exception handling mechanism to indicate failures. If the test method returns normally, the test is considered a success. If an unhandled exception or error is thrown by the method, the test is considered a failure. One of the most common ways of implementing this is by using a form of assertions.
Of course, you could simply add an assert
into the test code, then
enable assertions while running the test, but this approach means that
your tests could all incorrectly pass if you forget to enable
assertions. Instead, use the following import.
import static org.junit.jupiter.api.Assertions.*;
With this static import, you’ll have access to many static methods
that provide useful assertion functionality. The simplest of these is
assertTrue()
, which is essentially equivalent to an assert
without
requiring assertions to be enabled. For example, we could code the body
of the ceilTest()
method as follows.
@Test
public void ceilTest() {
assertTrue(4 == Math.ceil(3.1));
}
Another useful method is assertEquals()
(and its close cousin
assertArrayEquals()
) which takes two parameters and throws an
AssertionError
if the two aren’t equal. There are overloaded versions
of this method for other primitive types and Object
. Note that the preferred
assertEquals()
method for the double
type takes three parameters,
including a delta threshold in case the values don’t match exactly.
Using these methods, we can finally write a complete (though simple)
implementation of MathTest.java
.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class MathTest {
private static double sqrt2;
private static final double THRESHOLD = 0.000001;
@BeforeAll
public static void setUp() { sqrt2 = Math.sqrt(2);}
@Test
public void ceilTest() {
assertTrue(4 == Math.ceil(3.1));
}
@Test
public void powTest() {
assertEquals(2, Math.pow(sqrt2, 2), THRESHOLD);
}
@Test
public void sinTest() {
assertEquals(sqrt2/2.0, Math.sin(Math.PI / 4.0), THRESHOLD);
}
}
Note that the setUp()
method is extremely trivial here, and no clean
up is needed.
JUnit has many other powerful features that allow you to run suites of tests or repeated tests with specific parameterized values, but we’re only going to introduce one more feature here. In an ideal world, you develop tests as you develop code. In fact, a popular software development methodology is called test-driven development (TDD). The central idea behind TDD is writing your tests before you write your code. Doing so provides clarity about what you want your code to do, a way to know that you’ve written your code correctly, a way to document your code, and a suite of tests that grows along with your program.
Whether doing TDD or not, you might have completed a test for a specific
feature before you’ve finished implementing it. Or perhaps a feature
in your program is broken at the moment, but you want to continue
running tests on the rest of the features. In these cases and others, it’s
useful to turn off a particular test temporarily. To do this, you add the
annotation @Disabled
before the @Test
annotation. In parentheses after the
@Disabled
annotation, you should ideally put in parentheses a String
giving
the reason why the test is being disabled. There are even annotations that
allow you to run tests or disable them for particular platforms.
Running JUnit
Once you’ve created your JUnit test classes, you’ll want to run
them. There are tools built into IDEs like Eclipse to make running easier,
but the command line is always an option. As we said before, you need to
include the JUnit jar file in your classpath. You can either do this
permanently, by adding it to a CLASSPATH
environment variable in a way
dependent on your OS, or for a particular run of a Java tool. Assuming
that you haven’t added the jar file to your classpath permanently, let’s
say that you’re using the JUnit Platform 1.5.1 that’s included with JUnit
5.5.1 from a jar file called junit-platform-console-standalone-1.5.1.jar
that can be found in C:\Java\JUnit\
. If MathTest.java
is in the current
directory, you would type the following.
javac -classpath .;C:\Java\JUnit\junit-platform-console-standalone-1.5.1.jar MathTest.java
To actually run the tests, you need to tell the platform where to
look for tests to run. Thus, you run the platform from its jar file, specifying that the class
path is the current directory (.
), and then scan that directory for tests. Note that it’s
unnecessary to mention MathTest
at this point since all tests in the specified class path will
be run.
java -jar C:\Java\JUnit\junit-platform-console-standalone-1.5.1.jar --class-path . --scan-class-path
The output should include something like the following, showing that all three tests passed.
╷ ├─ JUnit Jupiter ✔ │ └─ MathTest ✔ │ ├─ ceilTest() ✔ │ ├─ powTest() ✔ │ └─ sinTest() ✔ └─ JUnit Vintage ✔
For the command line, this output is impressive, yet the commands listed above to run JUnit are complex. These commands have many additional options, allowing you to include or exclude additional paths, directories, and file names. While it’s possible to run JUnit commands from the command line, the process is smoother through an IDE.
16.8. Concurrency: Testing tools
In this section, we describe some tools that exist specifically to help catch those bugs that crop up as a direct result of concurrency. This section is short, and that shortness reflects the fewness of good tools available. The design of concurrent debugging and testing tools is still an open research topic. The heart of the problem is that the nondeterminism present in concurrency makes bugs difficult to pin down. You could run a JUnit test 1,000,000 times and never see a peculiar race condition. From a brute force perspective, we could try to test all possible interleavings of thread execution, but this approach is not practical for large programs because the number of interleavings grows exponentially. Nevertheless, some research has focused on attacking the problem from this direction.
16.8.1. IBM ConTest
One tool that used this idea was ConTest from IBM. Normal JVM
operation makes some interleavings more likely than others. If the
correct output is very likely and the incorrect is very unlikely, it’s
easy for a tester to believe that the program works correctly. ConTest was a
tool that instrumented class files after they’d been compiled by
Java. When it instrumented these files, it added extra method calls into
concurrent code designed to introduce some randomness into the system.
By introducing sleep()
and yield()
methods in random places, the JVM
could be forced into producing interleavings that would otherwise be
unusual. The designers of ConTest used heuristics so that ConTest
added this randomness in “smart” locations designed to maximize unusual
interleavings and catch bugs.
ConTest was not a panacea. Although it revealed bugs that were rare, it still had to be combined with strong testing methodologies so that those bugs could be caught when they appeared. Another difficulty with using ConTest was that it couldn’t tell you where the problem happened or when it was likely to happen under normal circumstances. You were still dependent on your test design to reveal the source of the problem. ConTest also didn’t guarantee every possible ordering. Very rare bugs might not have manifested even after thousands of runs with ConTest instrumented code. Although ConTest was one of the most promising tools taking this approach, it is no longer available.
16.8.2. Devexperts tools
The open-source Lin-check framework has some similarities to ConTest. It was designed by Devexperts specifically to test data structures such as those we will discuss in Chapter 18. Like ConTest, Lin-check executes tests in parallel many times in an effort to find a concurrent interleaving that causes an error condition.
The Dl-check tool was also designed by Devexperts. Its goal is to find deadlocks that could happen in concurrent programs.
16.8.3. ConcJUnit and ConcurrentUnit
We hope we’ve convinced you of the value of using JUnit testing to unit test your programs. Of course, JUnit has limitations, especially when testing concurrent programs. JUnit uses exceptions to report failed test cases, but JUnit only reports exceptions from the main thread, not from any child threads that might be spawned. ConcJUnit allows exceptions thrown by child threads to be reported and also forces all child threads to join with the main thread.
In this way, it will be clear if any errors happened while a child thread was being executed, either causing an exception to be thrown or causing a child thread to fail to rejoin the main thread. ConcJUnit is intended as a drop-in replacement for JUnit, but only for JUnit 3 and 4. ConcJUnit is no longer under active development and is unlikely to have a version compatible with JUnit 5. ConcJUnit is part of a larger suite of open-source tools called Concutest maintained at the Concutest site.
ConcurrentUnit is another testing framework with annotations similar to those found in JUnit. Although it’s not intended as a replacement for JUnit, it has facilities like ConcJUnit that allow testers to discover when an exception occurs on child threads.
16.8.4. SpotBugs
SpotBugs (previously called FindBugs), is a static analysis tool that examines programs for a long list of errors. SpotBugs looks for patterns that match known errors. Most of the errors that SpotBugs looks for have nothing to do with concurrency, but a whole section of its bug list is devoted to multithreaded correctness. SpotBugs can help you find incorrect usage of synchronization tools as well as unsafe concurrent library usage.
16.8.5. Intel® tools
There are industry tools for debugging and optimizing threaded programs outside of Java. Intel® produces software such as the Intel® Inspector to find concurrent errors as well as the Intel® Vtune™ Amplifier to help tune threaded programs. These products from Intel®, like many concurrency tools, are focused on C/C++ and Fortran platforms. Historically, concurrency has been centered in the high performance and scientific computing markets. Java, in contrast, has been perceived as a slow language, better suited to desktop applications. As the role of concurrency continues to evolve, so will the tools to help programmers.
16.9. Examples: Testing a class
The larger the system, the more critical testing becomes. We don’t have the space to explain a complex testing example, but we can provide another example of JUnit testing.
Using an example from physics, we can create a PointCharge
class that has
a certain charge and a specific location in 3D space. We’re also going to
introduce some errors into the class. Because the class is so simple, the
errors should be obvious. Nevertheless, we’ve picked reasonable errors that
might come up in real development.
public class PointCharge {
private double charge; // C
private double x; // m
private double y; // m
private double z; // m
public static final double K = 8.9875517873681764e9; // N m^2 C^-2
public PointCharge(double charge, double x, double y, double z) { (1)
this.charge = charge;
this.x = x;
this.y = y;
this.z = y;
}
public double getCharge() { return charge; }
public double distance(PointCharge p) { (2)
return distance(p.x, p.y, p.z);
}
private double distance(double x, double y, double z) { (3)
double deltaX = this.x - x;
double deltaY = this.y - y;
double deltaZ = this.z - z;
return Math.sqrt(deltaX*deltaX + deltaY*deltaY + deltaZ*deltaZ);
}
public double scalarForce(PointCharge p) { (4)
double r = distance(p);
return K*charge*p.charge/r*r;
}
public double fieldMagnitude(double x, double y, double z) { (5)
double r = distance(x, y, z);
return charge/(r*r);
}
}
1 | The PointCharge class has a straightforward constructor followed by an
accessor. |
2 | Then, it has a method to determine distance to another PointCharge . |
3 | This method in turn relies on a private helper method that can compute distance from an arbitrary x, y, and z location. |
4 | Some of the harder work done by the class can be found in a method to determine the scalar force between two charges. |
5 | Another method finds the magnitude of the electric field due to the charge at some location. |
Recall from physics that the force F between two charges q1 and q2 is given by the following equation.
In this equation, ke is the proportionality constant 8.9875517873681764 × 109 N·m2·C-2 and r is the distance between the charges. Likewise, the electric field E at a given location due to a charge q is given below.
Let’s come up with a test for the distance()
methods first. We’re
going to need some other PointCharge
objects. Let’s make four altogether:
one at the origin and three one meter along each positive axis. We can create
these charges in a set up method. While we’re at it, we’ll give them a
variety of positive and negative charges.
@BeforeEach
public void setUp() {
charge1 = new PointCharge(1, 0, 0, 0);
charge2 = new PointCharge(2, 1, 0, 0);
charge3 = new PointCharge(-1, 0, 1, 0);
charge4 = new PointCharge(0, 0, 0, 1);
}
To test the distance()
method thoroughly, we’ll check the distance
from charge1
to all the other charges as well as charge2
to
charge3
.
@Test
public void distance() {
assertEquals(1.0, charge1.distance(charge2), 0.000001);
assertEquals(1.0, charge1.distance(charge3), 0.000001);
assertEquals(1.0, charge1.distance(charge4), 0.000001);
assertEquals(Math.sqrt(2.0), charge2.distance(charge3), 0.000001);
}
The distances between charge1
and the other three should be 1, and the
distance between charge2
and charge3
should be around the square root of two.
Yet when we run this test with JUnit, the test
fails with the following error.
java.lang.AssertionError: expected:<1.0> but was:<1.4142135623730951>
This error happens on the second assertion in the method. But why? If we comb through the
distance()
methods in PointCharge
, they all look correct. The
problem must be deeper. PointCharge
doesn’t have accessor methods for
its location, so we can’t test those. Checking the constructor, we
find the culprit: this.z = y;
, a simple cut and paste error.
With the distance()
methods working, we can run a similar test for
scalarForce()
generated by plugging in appropriate values into the
equation for F.
@Test
public void scalarForce() {
assertEquals(2*PointCharge.K, charge1.scalarForce(charge2), 0.000001);
assertEquals(-PointCharge.K, charge1.scalarForce(charge3), 0.000001);
assertEquals(0.0, charge1.scalarForce(charge4), 0.000001);
assertEquals(-PointCharge.K, charge2.scalarForce(charge3), 0.000001);
}
When we run this test with JUnit, the last assertion fails. We get the following output.
java.lang.AssertionError: expected:<-8.987551787368176E9> but was:<-1.797510357473635E10>
A close inspection reveals that the actual value is about twice the
expected value. Where does this extra factor of 2 come from? Scanning
the code for scalarForce()
, we find return K*charge*p.charge/r*r;
We forgot parentheses and messed up our equation. What we really wanted
was return K*charge*p.charge/(r*r);
The most striking thing about this example is that three test cases passed! Perhaps that means that we were choosing values that were too simple, but it also illustrates the importance of thorough testing.
Finally, let’s test the value of the fieldMagnitude()
method. For
simplicity, we’ll test the field at the locations of charge1
,
charge3
, and charge4
with respect to charge2
.
This time the first assertion fails. We get the following output.
java.lang.AssertionError: expected:<1.797510357473635E10> but was:<2.0>
2.0
seems like a very strange result when we were expecting a value
with an order of magnitude 10 times larger. Perhaps a constant was
omitted? Yes, our version of fieldMagnitude()
left off a factor of
K
. Once we fix that, our code finally passes all three tests. Why didn’t
we fail the assertions after the first one? Because of the exception handling
mechanism, each JUnit test method stops once a failure has happened.
Here is the fully corrected version of PointCharge
renamed
FixedPointCharge
.
PointCharge
.public class FixedPointCharge {
private double charge; // C
private double x; // m
private double y; // m
private double z; // m
public static final double K = 8.9875517873681764e9; // N m^2 C^-2
public FixedPointCharge(double charge, double x, double y, double z) {
this.charge = charge;
this.x = x;
this.y = y;
this.z = z;
}
public double getCharge() { return charge; }
public double distance(FixedPointCharge p) {
return distance(p.x, p.y, p.z);
}
private double distance(double x, double y, double z) {
double deltaX = this.x - x;
double deltaY = this.y - y;
double deltaZ = this.z - z;
return Math.sqrt(deltaX*deltaX + deltaY*deltaY + deltaZ*deltaZ);
}
public double scalarForce(FixedPointCharge p) {
double r = distance(p);
return K*charge*p.charge/(r*r);
}
public double fieldMagnitude(double x, double y, double z) {
double r = distance(x, y, z);
return K*charge/(r*r);
}
}
And, for easy readability, here’s the full JUnit test class
TestPointCharge
. Note that you will have to change the name
PointCharge
to FixedPointCharge
if you want to test the corrected
class.
PointCharge
.import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class TestPointCharge {
private PointCharge charge1;
private PointCharge charge2;
private PointCharge charge3;
private PointCharge charge4;
@BeforeEach
public void setUp() {
charge1 = new PointCharge(1, 0, 0, 0);
charge2 = new PointCharge(2, 1, 0, 0);
charge3 = new PointCharge(-1, 0, 1, 0);
charge4 = new PointCharge(0, 0, 0, 1);
}
@Test
public void distanceTest() {
assertEquals(1.0, charge1.distance(charge2), 0.000001);
assertEquals(1.0, charge1.distance(charge3), 0.000001);
assertEquals(1.0, charge1.distance(charge4), 0.000001);
assertEquals(Math.sqrt(2.0), charge2.distance(charge3), 0.000001);
}
@Test
public void scalarForceTest() {
assertEquals(2*PointCharge.K, charge1.scalarForce(charge2), 0.000001);
assertEquals(-PointCharge.K, charge1.scalarForce(charge3), 0.000001);
assertEquals(0.0, charge1.scalarForce(charge4), 0.000001);
assertEquals((double)-PointCharge.K, (double)charge2.scalarForce(charge3), 0.000001);
}
@Test
public void fieldMagnitudeTest() {
assertEquals(2*PointCharge.K, charge2.fieldMagnitude(0, 0, 0), 0.000001);
assertEquals(PointCharge.K, charge2.fieldMagnitude(0, 1, 0), 0.000001);
assertEquals(PointCharge.K, charge2.fieldMagnitude(0, 0, 1), 0.000001);
}
}
16.10. Exercises
Conceptual Problems
-
What’s the purpose of the
assert
keyword in Java? What steps must be taken for it to be active? -
What’s the value of
j
after the following statements are executed?int j = 1; int i; for(i = 0; i < 10; i++); j += i;
Assuming the programmer made an error, what category of programming error does it fall under?
-
The following loop is intended to print out all possible
byte
values. What’s the conceptual error made in the following loop? How many times will it execute?for(byte value = 0; value < 256; ++value) System.out.println("Byte: " + value);
-
What are all the possible run-time errors that could occur in this method that reverses a section of an array?
public void reverse(Object[] array, int start, int end) { Object temp; end--; // Up to but not including end while(start < end) { temp = array[start]; array[start] = array[end]; array[end] = temp; start++; end--; } }
What checks could be added to catch these errors?
-
Recall the idea of a stack mentioned in Section 9.1. Consider the following definition of a stack designed to hold
int
values.public class IntegerStack { private static Node { public int data; public Node next; } private Node head = null; public void push(int value) { Node temp = new Node(); temp.data = value; temp.next = head; head = temp; } public void pop() { head = head.next; } public int top() { return head.data; } public boolean isEmpty() { return head == null; } }
What exceptions could be thrown when using this class? Where could they be thrown?
-
Imagine that you have a simple linked list such as the ones described in Section 18.2.2. What if there’s a loop in the list such that the last element in the list points to an earlier element in the list? For this reason, a simple traversal of the list will go on forever. How could you detect such a problem during program execution? Note that this question sometimes comes up in job interviews.
-
What’s the difference between black-box testing and white-box testing? What kinds of bugs are more likely to be caught by black-box testing? By white-box testing?
-
The Microsoft Zune was a portable media player in competition with the Apple iPod. The first generation Zune 30 received negative publicity because many of them froze on December 31, 2008 due to a leap year bug. It’s possible to find segments of the source code that caused this problem on the Internet. Essentially, the clock code for the Zune behaved correctly on any day of the year numbered 365 or lower. Likewise, when the day was greater than 366, it would correctly move to the next year and reset the day counter. When day was exactly 366, however, the Zune became stuck in an infinite loop. What kind of testing should Microsoft have done to prevent this bug?
Programming Practice
-
Apply JUnit testing to the last major assignment you did in class. What bugs did you uncover?
Experiments
-
James Gosling’s original specification for Java contained assertions, but they weren’t included until Java 1.4. One of the concerns about an assertion mechanism is the additional time required to process the assertions. Time a program of at least moderate length before adding
assert
statements to its methods. If you useassert
statements to check method input and output thoroughly, you should see a slight decrease in performance when assertions are enabled. When disabled, you should see almost none. How great is the performance hit? -
Take another look at your last programming assignment. Calculate the number of branches based on
if
andswitch
statements and compute 2 raised to that power. Time your program executing once under normal circumstances. Multiply that time by the number of different possibilities you would need to exercise every possible combination of branches in your program. How much total time would it take to run all of those different executions of your program? -
Take a concurrent program you’ve written that relies on explicit synchronization mechanisms for correctness. Remove all synchronization tools and run the code many times, testing for race conditions. How many runs does it take before a race condition causes an error? If you can, run your program on systems with different numbers of cores. Using a larger number of cores should make race conditions more evident since more processors can execute contentious code in parallel.