Lecture 20 Testing and Debugging

Joseph Haugh

University of New Mexico

Free Recall

  • Get out a sheet of paper or open a text editor
  • For 2 minutes write down whatever comes to mind about the last class
    • This could be topics you learned
    • Questions you had
    • Connections you made

Today’s Topics

  • Why testing is a skill, not an afterthought
  • Writing test cases: input + expected output
  • Choosing good tests: normal, edge, and boundary cases
  • Catching a non-obvious bug through testing
  • Debugging with print statements

Bugs Happen

  • You write code, it looks right, but produces wrong answers
  • Sometimes the wrong answer is obvious: the program crashes
  • Sometimes it is subtle: the program runs but gives a slightly wrong result
  • Testing: running your code with known inputs and checking the outputs
  • Debugging: once you know something is wrong, finding exactly where and why

The Simplest Test

int max(int a, int b) {
    if (a > b) return a;
    else return b;
}

The Simplest Test

int max(int a, int b) {
    if (a > b) return a;
    else return b;
}
void main() {
    IO.println(max(3, 7));    // expect 7
    IO.println(max(10, 4));   // expect 10
    IO.println(max(5, 5));    // expect 5
}
  • Each line is a test case: a specific input and what we expect as output
  • Run it, compare actual output to expected
  • You must know the answer before you run the code

A Test Case Has Two Parts

  • Input: the value(s) you pass to the method
  • Expected output: what you know the answer should be, computed by hand
  • A test passes if actual output matches expected
  • A test fails if they differ: there is a bug

Choosing Good Tests

  • Normal cases: typical inputs the method will usually see
    • max(3, 7), max(10, 4)
  • Edge cases: unusual or extreme inputs that might be handled differently
    • max(5, 5): what if both values are equal?
    • max(-1, -5): what about negatives?
  • Boundary cases: inputs right at the limit of what is allowed
    • Empty array, single element, first index, last index, minimum/maximum int

Example: countVowels

int countVowels(String s) {
    int count = 0;
    for (int i = 0; i < s.length(); i++) {
        char c = s.charAt(i);
        if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') {
            count++;
        }
    }
    return count;
}

countVowels: Normal Tests

void main() {
    IO.println(countVowels("hello"));    // expect 2
    IO.println(countVowels("java"));     // expect 2
    IO.println(countVowels("rhythm"));   // expect 0
}
  • All three pass. Is the method correct?
  • Not so fast: we have not tried edge cases yet

countVowels: Edge Cases

void main() {
    IO.println(countVowels("hello"));    // expect 2  -- PASS
    IO.println(countVowels("java"));     // expect 2  -- PASS
    IO.println(countVowels("rhythm"));   // expect 0  -- PASS
    IO.println(countVowels(""));         // expect 0  -- PASS
    IO.println(countVowels("HELLO"));    // expect 2  -- FAIL: prints 0!
}
  • Empty string: works fine
  • Uppercase: fails because the method only checks lowercase vowels
  • Testing edge cases caught a real bug

Fixing countVowels

int countVowels(String s) {
    int count = 0;
    for (int i = 0; i < s.length(); i++) {
        char c = Character.toLowerCase(s.charAt(i));
        if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') {
            count++;
        }
    }
    return count;
}
  • Convert each character to lowercase before checking
  • Now 'H' becomes 'h' and is correctly counted
  • Re-run all the tests to confirm everything still passes

A Non-Obvious Bug

double average(int[] nums) {
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    return sum / nums.length;
}
  • This looks reasonable. Can you spot anything wrong?

Testing average: Normal Cases

void main() {
    int[] a = {2, 4, 6};
    IO.println(average(a));       // expect 4.0

    int[] b = {10, 20, 30, 40};
    IO.println(average(b));       // expect 25.0
}
  • Both pass. Feeling confident?

Testing average: A Trickier Case

void main() {
    int[] a = {2, 4, 6};
    IO.println(average(a));       // expect 4.0  -- PASS

    int[] b = {10, 20, 30, 40};
    IO.println(average(b));       // expect 25.0 -- PASS

    int[] c = {1, 2};
    IO.println(average(c));       // expect 1.5  -- FAIL: prints 1.0!
}
  • The method returns 1.0 instead of 1.5
  • The first two tests passed only because their answers happened to be whole numbers
  • Now we need to debug: find out exactly why the wrong answer is produced

Debugging: Add Print Statements

double average(int[] nums) {
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    IO.println("sum    = " + sum);
    IO.println("length = " + nums.length);
    IO.println("sum / length = " + (sum / nums.length));
    return sum / nums.length;
}

What the Prints Tell Us

double average(int[] nums) {
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    IO.println("sum    = " + sum);           // sum    = 3
    IO.println("length = " + nums.length);   // length = 2
    IO.println("sum / length = " + (sum / nums.length)); // sum / length = 1
    return sum / nums.length;
}
  • sum is 3 and length is 2, both correct
  • But 3 / 2 in Java is integer division: it truncates the decimal, giving 1
  • The return type is double, but the division happens with two int values first
  • The result 1 is then converted to 1.0, which is too late to get 1.5

Fixing average

double average(int[] nums) {
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    return (double) sum / nums.length;
}
  • Cast sum to double before dividing
  • Now (double) 3 / 2 is 3.0 / 2, which gives 1.5
  • Watch out: (double)(sum / nums.length) would not fix it; that still divides as ints first, then casts
  • Remove the debug prints once the fix is confirmed
  • Re-run all tests to make sure nothing is broken

Wait – Is There Another Bug?

double average(int[] nums) {
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    return (double) sum / nums.length;
}
  • We fixed integer division. Are we done?
  • What happens if someone calls average(new int[]{}) ?

Testing average: The Empty Array

void main() {
    int[] a = {2, 4, 6};
    IO.println(average(a));       // expect 4.0  -- PASS

    int[] b = {10, 20, 30, 40};
    IO.println(average(b));       // expect 25.0 -- PASS

    int[] c = {1, 2};
    IO.println(average(c));       // expect 1.5  -- PASS

    int[] d = {};
    IO.println(average(d));       // expect ??? -- CRASH: divide by zero!
}
  • nums.length is 0, so sum / 0 throws ArithmeticException
  • This is a boundary case: empty input is always worth testing

Fixing average: Empty Array

double average(int[] nums) {
    if (nums.length == 0) {
        return 0.0;
    }
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    return (double) sum / nums.length;
}
  • Check for an empty array before dividing
  • Returning 0.0 is a reasonable sentinel; some designs throw an exception instead
  • The lesson: always test boundary cases – especially empty inputs

The Debugging Process

  1. Reproduce the failure: find a specific input that gives the wrong output
  2. Form a hypothesis: what part of the code might cause this?
  3. Add print statements: print intermediate values to see what is actually happening
  4. Find where values go wrong: compare actual vs. expected at each step
  5. Fix and re-test: make the fix, then run all your tests again

Common Bugs to Test For

Logic

  • Off-by-one in a loop (< vs <=)
  • Wrong condition in an if
  • Using = instead of ==
  • Using == instead of equals

Arithmetic

  • Integer division when you need a decimal
  • Overflow: value too large for int
  • Dividing by zero

Edge cases that often reveal bugs

  • Empty array or string
  • Single element
  • All elements the same
  • Negative numbers

Summary

  • Test early and often: do not wait until the whole program is done
  • Know the answer first: compute expected output by hand before running
  • Include edge cases: empty input, negatives, equal values, single elements
  • When a test fails, debug: add prints to trace what the program actually computes
  • Re-test after fixing: confirm the fix works and did not break anything else