Module 2: Debugging

Module Overview

In this module, you'll learn systematic approaches to debugging Java applications. You'll understand how to identify, isolate, and fix bugs efficiently, and master debugging tools available in the Java ecosystem.

Learning Objectives

  • Apply the scientific method to diagnose and fix bugs in Java applications
  • Use debugging tools effectively, including IDEs, logging, and system diagnostics
  • Implement strategies to reduce debugging time and increase solution accuracy
  • Trace program execution and identify logical errors in code
  • Debug concurrent applications and understand threading issues
  • Apply remote debugging techniques for server applications

Key Topics

Introduction to Debugging

Learn fundamental debugging concepts and methodologies for systematic problem-solving.

Overview

In this lesson, we'll examine three techniques for finding flaws in code. These flaws, or bugs, pose a serious challenge to developers since they're often difficult to find and cause unexpected behavior.

We introduce the scientific method for bug finding, automatic testing, and the IntelliJ debugger tool. We show how a small, incorrect program can be repaired using these techniques and explain how each method can be generalized to fit programs of all sizes.

Introduction to debugging

Consider the following:

String s1 = "foo";
String s2 = new String("foo");
if (s1 == s2) {
    System.out.println("the strings are equal");
} else {
    System.out.println("the strings are not equal");
}

What's the output of the code above? From the content of the print statements, we rightly assume that the if statement is intended to compare the values of strings s1 and s2. Yet when we run this example, the output is

the strings are not equal

We've discovered a bug! A bug is a fault in a computer program that causes it to produce unexpected or incorrect results. Bugs are a widespread problem in software development, even in professional environments. They most often occur due to mistakes or misunderstandings made during implementation. Some particularly tricky bugs also arise during the design of complex software. For a simple example, the code above produces unexpected results because of a misuse of Java's == operator. The == operator compares the equality of objects, not values. Even though s1 and s2 evaluate to the same value; they're not the same object. One is built from a string literal, while the other uses the String constructor. They aren't equivalent under ==. We can fix the problem by using the equals method, which considers only the values of each object:

String s1 = "foo";
String s2 = new String("foo");
if (s1.equals(s2)) {
    System.out.println("the strings are equal");
} else {
    System.out.println("the strings are not equal");
}

This fixed version produces the expected:

the strings are equal

Because bugs interfere with a program's ability to function, finding and fixing bugs is critical for developers. Even in small example programs, bugs can be tough to find. As software increases in complexity, a developer needs to be able to find and repair flaws methodically. We call this process debugging.

Finding bugs

Finding bugs is perhaps the most challenging part of software development. Even teams of experienced professionals can have difficulty finding bugs. After all, if finding bugs was easy, there would be far fewer errors in production software.

To make finding bugs easier, developers use a systematic technique called the scientific method. The steps of the scientific method are:

  1. Reproduce the bug with a small, repeatable test
  2. Study the available data
  3. Form a hypothesis about the location of the bug
  4. Experiment to test the hypothesis
  5. Repeat steps 2-4 until the bug is localized

We'll explore each step in detail by debugging the program below.

public class MonthFinder {
    public static void main(String[] args) {
        int monthId = Integer.parseInt(args[0]);
        String monthName = findMonth(monthId);
        System.out.println("the month is " + monthName);
    }

    public static String findMonth(int monthId) {
        String monthName = "";
        switch (monthId) {
            case 1:
                monthName = "January";
                break;
            case 2:
                monthName = "February";
                break;
            case 3:
                monthName = "March";
            case 4:
                monthName = "April";
                break;
            case 5:
                monthName = "May";
                break;
            case 6:
                monthName = "June";
                break;
            case 7:
                monthName = "July";
                break;
            case 8:
                monthName = "August";
                break;
            case 9:
                monthName = "September";
                break;
            case 10:
                monthName = "October";
                break;
            case 11:
                monthName = "November";
                break;
            case 12:
                monthName = "December";
                break;
        }
        return monthName;
    }
}

This program takes an integer monthId as a command-line argument and prints the corresponding month name. For example, the first month is January, so on input 1, the result is:

the month is January

We expect the third month to be March. However, when the input is 3, the result is:

the month is April

We have a bug! Since the program compiles and runs without error, we know our bug must result from faulty program logic. Now, we must find it by following the steps of the scientific method.

Reproduce the bug

The first step in debugging is to find a small, easily repeatable test case that reproduces the bug. Most of the time, this means running a program with a bit of input that causes the bug to appear. The input must be small, so the results are easy to analyze. If your input is larger or more complex, it may be more difficult to determine the cause of the bug. A small, simple, reproducible test case will help narrow down what does and doesn't work.

In our case, the output size is fixed, so we don't need to worry about our test case generating too much data to analyze. We already know that input 3 produces an incorrect answer and does so consistently. This makes it an excellent test case, so we'll use it as our input from now on.

Study the data

Our next step is to study all the available information. If a bug causes the program to crash, this is where we'll take a moment to examine the failure messages and stack trace. Our example doesn't crash, so we only have our input and output and a general understanding of the program structure to go by.

We know our input 3 causes the program to print an incorrect month name to the standard output, but other inputs work as expected. We also know that most of the work of our program is done by the findMonth method. These facts will come in handy when we need to form a hypothesis about the location of our bug.

Form a hypothesis

A hypothesis is an assumption about an event that an experiment can test. In the case of debugging, we propose hypotheses about where a bug can or can't be. It's a good idea to make broad hypotheses first, then narrow them down as we experiment. Because the only input that produces an incorrect month is 3, we can assume that the call to parseInt in the main method works as expected. Likewise, we can assume the print statement in main isn't the cause of our bug. This leaves only findMonth as the possible culprit. We hypothesize that there's a fault in the switch statement of findMonth.

Conduct an experiment

Once we've proposed a hypothesis, we consider how to test it. An experiment might be a different test case, or it could involve inserting probes into the code to collect extra data. One common technique is adding print statements to the code. Print statements are useful for determining when the program reaches certain blocks of code. They're also used to print the values of variables while the program is running, so we can see where the program stops working as expected. Because we want to test the hypothesis that there's a problem with the switch statement in findMethod, we'll add two print statements: one where the case 3 is reached and one right before the return statement.

public static String findMonth(int monthId) {
    String monthName = "";
    switch (monthId) {
        case 1:
            monthName = "January";
            break;
        case 2:
            monthName = "February";
            break;
        case 3:
            monthName = "March";
            System.out.println(monthName);
        case 4:
            monthName = "April";
            break;
        case 5:
            monthName = "May";
            break;
        case 6:
            monthName = "June";
            break;
        case 7:
            monthName = "July";
            break;
        case 8:
            monthName = "August";
            break;
        case 9:
            monthName = "September";
            break;
        case 10:
            monthName = "October";
            break;
        case 11:
            monthName = "November";
            break;
        case 12:
            monthName = "December";
            break;
    }
    System.out.println(monthName);
    return monthName;
}

These probes allow us to track the value of monthName through to the end of the method. After running with the input 3, the results of the print statements are:

March
April

We see that the value of monthName is "March" (the correct value), but before the end of the method, it changes to the incorrect "April". We've verified our hypothesis!

Repeat

After verifying a hypothesis, we use the information we collected to form a new, more specific hypothesis. Analyzing the new data available to us, we see the problem lies somewhere in the switch statement. We also know there's only one place in findMonth that assigns the value "April" to monthName. When we examine this part of the program closely, we notice no break statement between the third and fourth cases of the switch statement. Our new hypothesis is that this missing break statement is the cause of our bug.

We add the break statement to the program:

public static String findMonth(int monthId) {
    String monthName = "";
    switch (monthId) {
        case 1:
            monthName = "January";
            break;
        case 2:
            monthName = "February";
            break;
        case 3:
            monthName = "March";
            // adding the break statement
            break;
        case 4:
            monthName = "April";
            break;
        case 5:
            monthName = "May";
            break;
        case 6:
            monthName = "June";
            break;
        case 7:
            monthName = "July";
            break;
        case 8:
            monthName = "August";
            break;
        case 9:
            monthName = "September";
            break;
        case 10:
            monthName = "October";
            break;
        case 11:
            monthName = "November";
            break;
        case 12:
            monthName = "December";
            break;
    }
    return monthName;
}

Then we run the program on our test input:

the month is March

Success! Not only did we localize the bug, our experiment just so happened to fix it! This isn't always the case, particularly in complicated programs.

In general, it's important to avoid adding code to a program while debugging it. Early attempts to fix a problem may make it harder to determine the actual issue. Worse, new code could potentially introduce even more bugs to a program. Only add code or edit code as part of an experiment. Be sure that each change is well-documented and reversible.

Automatic testing

There are many different types of tests in software development. Regression tests are designed to alert developers to "regressions" or breaking changes made to code. Unit tests ensure that each individual piece of code runs as expected in isolation. Integration tests, on the other hand, test the functionality of the software as a whole. In this lesson, we'll focus on writing tests to debug software, but we'll also revisit testing in future lessons.

Debugging often involves running several tests in sequence. Because each test needs to run after every build, the process can quickly become tedious. Fortunately, Java allows us to write and run our tests automatically, making developing code significantly easier. Let's start by defining a class to contain our testing methods:

public class MonthFinderTests {

}

Adding "Tests" to the name of the class we want to test is a common convention in Java development. It helps us to manage the structure of our code. To MonthFinderTests we add the following method skeleton:

public boolean findMonth_GivenThree_ReturnsMarch() {

}

Tests are often named according to the purpose they serve. We'll use our test to determine whether the findMonth method returns the correct month given an input of 3. The code to do so is as follows:

public boolean findMonth_GivenThree_ReturnsMarch() {
    String expected = "March";
    String result = findMonth(3);

    return expected.equals(result);
}

First, we initialize the variable expected with the value we want findMonth to produce. Then, we call findMonth and store the returned value in the variable result. Finally, we compare the values of both variables and return the result as a boolean.

To let our code run automatically and produce useful results, we'll add the following to the beginning of our main method:

if (findMonth_GivenThree_ReturnsMarch()) {
    System.out.println("test passed: findmonth_GivenThree_ReturnsMarch()");
} else {
    System.out.println("test failed: findmonth_GivenThree_ReturnsMarch()");
}

Now, whenever we run our program, our test results will print to the standard output. Depending on our preference, we may wish to omit a print statement when the test is successful and only print test failures.

Adding the test while debugging makes keeping track of existing bugs easier. When a bug is found and repaired, a test like findMonth_GivenThree_ReturnsMarch() should remain in the program to prevent a similar bug from appearing later in the software's lifetime. We will learn more about how to write automated tests for our programs in future lessons.

The IntelliJ debugger

So far, we've debugged entirely by hand. Although automated tests save us time and effort when repeatedly testing a program, we still rely on print statement probes and repeatable test cases to localize bugs. For many run-of-the-mill bugs, the manual approach is sufficient. However, bugs resulting from logic errors can be tricky to discover, even if we're using the scientific method. Luckily, many IDEs come with a handy bug-finding tool: the debugger.

We'll return to our original, buggy version of MonthFinder and show how using the debugger packaged with IntelliJ makes short work of finding bugs. As before, we must first find a small, repeatable test case, study our data, and form a hypothesis. Once we have an idea about the bug location, we insert a breakpoint in the editor window. A breakpoint suspends the execution of a program so that we can examine its internal state. To add a breakpoint, click the gutter (the space between the line numbers and the code) at the appropriate line. Figure 1 shows a breakpoint set at line 4 of our program.

Figure 1

Figure 1: The IntelliJ code editor window. A red dot, indicating the breakpoint, is shown on line 4.

As with print statement probes, we can only guess where to insert breakpoints. Having a well-reasoned hypothesis is a good defense against wasteful breakpoints, slowing down the debugging process. Practice also helps; the more we use the debugger, the better our intuition for setting breakpoints will become. In our example, we set a breakpoint at line 4 since we hypothesized that the findMonth method was the source of our bug.

With the breakpoints set, we can run the program in debug mode from the Run menu. Select Edit configurations and ensure the proper command-line arguments are passed to main. We'll pass the input 3, as before.

Click the green Run button near the main method and select Debug. Once the debugger begins, the program will run as normal until the breakpoint is reached. The program is then suspended, and additional information is displayed, as shown in Figure 2.

Figure 2

Figure 2: The IntelliJ code editor window. The program has been suspended, and the values of each variable in scope are displayed inline.

The debugger stops the execution of the program right before our breakpoint on line 4. The method findMonth hasn't been called yet, but we can still examine the contents of its argument. The value of monthId is displayed inline: 3, as expected. The debug panel (shown in Figure 3) also opens in IntelliJ, giving useful information about each of the variables in use.

Once the program's execution has been suspended, we can "step into" any called methods to examine them in detail. "Stepping into" a method means that when our code calls another method, our debugger will enter into the code for that method, which allows us to examine its execution line by line. Since we hypothesized that the bug was in findMonth, let's step into the findMonth method. To do this, we click the Step Into button on the debug panel, as shown in Figure 3.

Figure 3

Figure 3: Part of the IntelliJ debug panel. The Step Into button, marked by a blue down-facing arrow, is highlighted.

By repeatedly clicking the Step Into button, we progress through the execution of findMonth one line at a time. As each line is evaluated, the state of the program is displayed inline. By going through the execution of the code line by line, we would see the program does set monthName to "March" on line 18 initially, but later executes line 20, which sets the monthName to "April". Figure 4 shows the state of findMonth after the value of monthName has been changed to "April".

Figure 4

Figure 4: The IntelliJ code editor window. The findMonth method is shown, with the value of monthName displayed inline.

By using the debugger, we can easily track the values of each variable as we execute our program. Determining when unexpected behavior first appears is useful for debugging. It's much easier using the debugger than just using print statements alone.

Conclusion

In this reading, we've examined three strategies for debugging. The scientific method is the first technique that developers should use since it informs the use of both automatic testing and the debugger. With some practice, debugging will feel as natural a part of software development as implementation.

The Practice of Debugging

Mastery Task 1: Grace Under Pressure - Milestone 1

Mastery Task Guidelines

Mastery Tasks are opportunities to test your knowledge and understanding through code. When a mastery task is shown in a module, it means that we've covered all the concepts that you need to complete that task. You will want to make sure you finish them by the end of the week to stay on track and complete the unit.

Each mastery task must pass 100% of the automated tests and code styling checks to pass each unit. Your code must be your own. If you have any questions, feel free to reach out for support.

Admiral Grace Hopper is famously known for being a real-life, literal debugger thanks to her logbook entry describing how she found and removed a moth from a computer relay in one of the early room-sized computers. The Missed Promise CLI system also has bugs (although in this case they are not literally insects), and in this task you will find and eliminate one of them.

A CS representative has filed a bug report, stating that when they request the promise history for order ID 111-749023-7630574, the Missed Promise CLI prints a weird message and exits:

Running CLI! Please enter the orderId you would like to view the Promise History for.
> 111-749023-7630574
Error encountered. Exiting.
Thank you for using the Promise History CLI. Have a great day!

Over the course of this sprint, you will determine why this happens, replace the error message with an informative one, prevent the tool from exiting when this error occurs, and make sure it never happens again.

Milestone 1: Find the Bug

Reproduce the problem in your workspace by running the code and verifying that it prints the message the CS representative reports. Then run the CLI in IntelliJ and use IntelliJ's debugger and breakpoints to determine why the error message occurs NOTE: The type of bug you will be looking for is a NULL Pointer Exception. Add a comment where you think the fix should go (start with "// FIXME...") so you can return to it in Milestone 2.