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


  1. Writing Test Cases
  2. Testing Console Functions
  3. Testing Graphics Functions
  4. Exception Handling

  1. 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.
      
      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()

  2. Testing Console Functions
    • When we write functions that use console input and/or output instead of traditional argument input/output, we have to use special test functions. After all, we can't assert that a function's returned value matches expectations when it doesn't return!
    • To test console functions we intercept the system input and output streams, providing the input of the test case and checking that the output matches what we expect. You are not responsible for understanding how this code works, but you will occasionally need to use it on homework assignments.

    • Console Test Code:
      def ioTest(testInput):
          import sys, io
          myOut = io.StringIO()
          myIn = io.StringIO(testInput)
          sys.stdout = myOut
          sys.stdin = myIn
          foo()
          return myOut.getvalue()
      
      def testFoo():
          import sys
          print("Testing foo()...", end="")
          tmpOut = sys.stdout
          tmpIn = sys.stdin
          resultOne = ioTest("Test One Input\n")
          resultTwo = ioTest("Test Two Input\n")
          sys.stdout = tmpOut
          sys.stdin = tmpIn
          assert(resultOne == "Test One Expected Output\n")
          assert(resultTwo == "Test Two Expected Output\n")
          print("Passed.")

  3. Testing Graphics Functions
    • For now, it is too difficult for us to write test cases that can verify whether graphics functions work correctly. A single pixel being out of place might not bother us, but it will bother the computer!
    • Instead, test your graphics functions by running them and seeing if they look right. Does the appearance translate correctly when the width or height is changed? What if the window is really big, or really small?
    • When testing animations, you must also test multiple kinds of input. What happens when you click on various parts of the screen, or type varying keys? What happens if you close the program quickly, or let it run for 5+ minutes? Testing animations in various scenarios will help you catch bugs.

  4. Exception Handling  
    • Basic Try/Except Structure:
      # The basic try/except structure catches exceptions from a block of code by 
      # putting it in a try statement. The except block tells you what to do when 
      # an exception is caught.
      print("This is a demonstration of the basics of try/except.")
      try:
          print("Here we are just before the error!")
          print("1/0 equals:", (1/0))
          print("This line will never run!")
      except:
          print("*** We just caught an error. ***")
      print("And that concludes our demonstration.")

    • Using Exceptions:
      # If you want to gather more information about the exceptions that are caught, 
      # you can include them in the except statement and print the exception object 
      # to learn more.
      print("This is a demonstration of an exception object:")
      try:
          print("Here we are just before the error!")
          print("1/0 equals:", (1/0))
          print("This line will never run!")
      except Exception as e:
          print("Here's the error: ", e)
      print("And that concludes our demonstration.")

    • Catching Specific Exceptions:
      # If you only want to catch certain exceptions, 
      # you can limit the type of the except statement.
      print("This is a demonstration of an exception object:")
      try:
          shouldCrash = True
          print("Here we are just before the error!")
          if shouldCrash:
              print("1/0 equals:", (1/0))
          else:
              assert(False)
      except AssertionError as e:
          print("Here's the error: ", e)
      print("And that concludes our demonstration.")

    • Raising Exceptions:
      # We can also raise our own homemade errors, 
      # if we want to give information to the user.
      def lastChar(s):
          if (len(s) == 0):
              # This is (a simple form of) how you raise your own custom exception:
              raise Exception('String must be non-empty!')
          else: return s[-1]
      
      print(lastChar('abc'))
      print(lastChar(''))
      print("This line will never run!")