CMU 15-112: Fundamentals of Programming and Computer Science
Class Notes: Debugging


 Learning Goal: determine that a program works correctly by:
  1. Syntax, Runtime, and Logical Errors
  2. Check 1.11
  3. Test Functions
  4. Writing Test Cases
  5. Check 1.12
  6. Avoiding Bugs
  7. Debugging Syntax Errors
  8. Debugging Runtime Errors
  9. Debugging Logical Errors


  1. Syntax, Runtime, and Logical Errors
    • Syntax Errors (Compile-Time Errors)
      print("Uh oh!) # ERROR! missing close-quote # Python output: # SyntaxError: EOL while scanning string literal

    • Runtime Errors ("Crash")
      print(1/0) # ERROR! Division by zero! # Python output: # ZeroDivisionError: integer division or modulo by zero

    • Logical Errors (Compiles and Runs, but is Wrong!)
      print("2+2=5") # ERROR! Untrue!!! # Python output: # 2+2=5

  2. Check 1.11

  3. Test Functions
    • We can write test functions using assert statements
      assert(5 + 4 == 9) # when the expression in an assert is True, it does nothing assert(6 < 2) # when the expression in an assert is False, it crashes

    • A test function tests a given function by asserting that the function returns the correct output for a variety of inputs
      def onesDigit(n): return n%10 def testOnesDigit(): print("Testing onesDigit()...", end="") assert(onesDigit(5) == 5) assert(onesDigit(123) == 3) assert(onesDigit(100) == 0) assert(onesDigit(999) == 9) assert(onesDigit(-123) == 3) print("Passed!") testOnesDigit() # Crashed! So the test function worked!

  4. Writing Test Cases
    • Writing test cases is part of the process of understanding a problem; if you don't know what the result of an example input should be, you can't know how to solve the problem.
    • Test cases are also used to verify that a solution to a problem is correct, that it works as expected. Without a good set of test cases, we have no idea whether our code actually works!
    • Needed test cases vary based on the problem, but you generally want to ensure that you have at least one or two of each of the following test case types.
      • Normal Cases: Typical input that should follow the main path through the code.
      • Large Cases: Typical input, but of a larger size than usual. This ensures that bugs don't appear after multiple iterations.
      • Edge Cases: Pairs of inputs that test different choice points in the code. For example, if a condition in the problem checks whether n < 2, two important edge cases are 2 and 3, which trigger different behaviors. Other edge cases include the first/last characters in a string or items in a list.
      • Special Cases: Some inputs need to be special-cased for many problems. This includes negative numbers/0/1 for integers, the empty string/list/dictionary, and (when needed) input values of different types than are expected
      • Varying Results: Finally, test cases should cover multiple possible results. This is especially important for boolean functions; make sure that you have both True and False among your results!

    • Example:
      # Sample code for our discussion on writing good test functions. # Your test functions should include as many tests as necessary, # but not more. Each test should have a reason to be there, # covering some interesting edge case or scenario. # For now, don't worry about how isPrime works. Focus instead on why each test case should exist. def testIsPrime(): print("Testing isPrime()...") assert(isPrime(-1) == False) # negative assert(isPrime(0) == False) # zero assert(isPrime(1) == False) # 1 is quite the special case assert(isPrime(2) == True) # 2, only even prime assert(isPrime(3) == True) # 3, smallest odd prime assert(isPrime(4) == False) # 4, smallest even non-prime assert(isPrime(9) == False) # 9, perfect square of odd prime assert(isPrime(987) == False) # somewhat larger non-prime assert(isPrime(997) == True) # somewhat larger prime print("Passed!") def workingIsPrime1(n): if (n < 2): return False for factor in range(2, n): if (n % factor == 0): return False return True def workingIsPrime2(n): if (n == 2): return True if ((n < 2) or (n % 2 == 0)): return False for factor in range(2, int(round(n**0.5))+1): if (n % factor == 0): return False return True def brokenIsPrime1(n): # if (n < 2): return False # broken (commented out) for factor in range(2, n): if (n % factor == 0): return False return True def brokenIsPrime2(n): if (n < 1): return False # broken: 2 -> 1 for factor in range(2, n): if (n % factor == 0): return False return True def brokenIsPrime3(n): if (n < 2): return False for factor in range(2, n+1): # broken: n -> n+1 if (n % factor == 0): return False return True def brokenIsPrime4(n): if (n < 2): return False for factor in range(2, n): if (n % factor == 0): return False else: # broken: no "else", should be after loop return True def brokenIsPrime5(n): if (n == 2): return True if ((n < 2) or (n % 2 == 0)): return False for factor in range(2, int(round(n**0.5))): # broken, omitted +1 if (n % factor == 0): return False return True def raisesAssertion(f, *args): # Helper fn for testing test function. You are responsible # for what this function does, but not how it does it. try: f(*args) except AssertionError: return True return False def testTestIsPrime(): print("Testing testIsPrime()...") global isPrime # Store the "real" function so we can restore it after our tests try: realIsPrime = isPrime except: realIsPrime = None # Now test our working and broken versions isPrime = workingIsPrime1 assert(raisesAssertion(testIsPrime) == False) isPrime = workingIsPrime2 assert(raisesAssertion(testIsPrime) == False) isPrime = brokenIsPrime1 assert(raisesAssertion(testIsPrime) == True) isPrime = brokenIsPrime2 assert(raisesAssertion(testIsPrime) == True) isPrime = brokenIsPrime3 assert(raisesAssertion(testIsPrime) == True) isPrime = brokenIsPrime4 assert(raisesAssertion(testIsPrime) == True) isPrime = brokenIsPrime5 assert(raisesAssertion(testIsPrime) == True) # And restore the "real" version isPrime = realIsPrime print("Passed!") testTestIsPrime()

  5. Check 1.12

  6. Avoiding Bugs
    Bugs are a natural part of the programming process. However, you can reduce the number of bugs you encounter by following a few tips:
    • Write code with good style.
    • Write tests before writing functions, and test as you go.
    • Make sure each function only has one task.
    • Avoid copying and pasting code at all costs (this leads to bug propagation).

  7. Debugging Syntax Errors
    1. When debugging a syntax error, the most important thing you can do is read the error message!
    2. Start with the bottom line. It will verify that this is a syntax error.
    3. Look for the line number. It will appear at the bottom of the stack trace.
    4. Then look carefully at where in the line the arrow is pointing. That is usually where the syntax error is.
    5. Common syntax errors include:
      • Forgetting to close a string or parenthesis (may result in an EOF error)
      • Forgetting a colon
      • Using = instead of ==, or vice versa
      • Mismatched indentation (automatically convert tabs to spaces to avoid this!)
      • And many more...

  8. Debugging Runtime Errors
    1. When debugging a runtime error, the most important thing you can do is, again, read the error message!
    2. Start with the bottom line. The type of the error will probably tell you what's going wrong.
    3. Look for the line number. It will appear at the bottom of the stack trace.
    4. Go to that line in the code, and identify what part of that line might be associated with the runtime error. If there are multiple possibilities, split them up into separate lines and run the code again.
    5. If it seems like the data you're inputting shouldn't result in that runtime error, try tracing the code of your program with the input. You can use print statements in your code to identify where your expectations diverge from what the code is doing. It's especially helpful to print the data on the line before the error occurs, to see what the state of the program is.
    6. Finally, determine how your algorithm needs to change to handle the new program state.
    7. Common runtime errors include:
      • String/list index errors
      • Having a typo in a variable name
      • Infinitely-recursing programs
      • Trying to use an operation on an inappropriate type
      • Dividing by zero
      • Trying to read a file that doesn't exist
      • And many more...

  9. Debugging Logical Errors
    1. When debugging a logical error (aka, trying to figure out why you're failing a test case), the most important thing you can do is identify the input, expected output, and actual output of the failing case.
    2. Does the expected output make sense to you? If it does not, re-read the problem prompt until you understand it.
    3. Start tracing your code to determine when it starts behaving differently from what you expected. Usually this can be done by adding print statements at important conjunctures of the code, to display the current program state.
      • It helps to pair the values you're printing with meaningful labels. Instead of print(n), try print("n before for:", n).
      • It also helps to put some debugging statements into conditionals, so they're only printed when a certain (bad) condition is met.
      • When printing strings that include whitespace, use repr(s) to display the escape sequences in a more readable format.
      • If your program is very large, try using binary-search-print-debugging! Print the program state in the middle to see if the problem occurs before or after that point, move to the affected area, and repeat until the problem is found.
    4. Once you've found the location of the error, use problem-solving approaches to determine how your algorithm needs to be changed.