30 Days Of Python 👨‍💻 - Day 20 - Debugging And Testing

This article is a part of a 30 day Python challenge series. You can find the links to all the previous posts of this series here
Developers don’t write perfect code and so the applications we try to build are prone to various kind of errors or exceptions or bugs. It's more than just knowing the syntax and the concepts of a programming language, it's about being able to detect bugs by debugging and writing tests to ensure our code works on different practical scenarios. So to be able to write good quality code with Python, I explored the concepts of debugging and testing, mainly writing unit tests today.
 

Debugging

 
Debugging is the computer science term for finding potential exceptions or bugs in our code. It comes in extremely handy to know the concept of debugging code to understand the cause of bugs that cause unwanted behavior in our applications.
 
Python comes with the tools necessary for debugging code out of the box with its in-built functions. The more naïve and simple way to debug code is often using a print statement which I tend to use frequently.
 
main.py
  1. def is_prime(num):    
  2.   if num > 1:    
  3.     for i in range(2,num):    
  4.         if (num % i) == 0:    
  5.             return False    
  6.     else:    
  7.         return True    
  8.             
  9.   else:    
  10.     return False    
  11.     
  12. result = is_prime('2')    
  13. print(result) # TypeError   
The above block of code gives a type error. Now the simple way to check what is wrong is byplacing a print statement to know what is happening inside the function.
 
main.py
  1. def is_prime(num):    
  2.     print(num) # 2 (Here using a print might confuse us!)    
  3.     if num > 1:    
  4.         for i in range(2, num):    
  5.             if (num % i) == 0:    
  6.                 return False    
  7.         else:    
  8.             return True    
  9.     
  10.     else:    
  11.         return False    
  12.     
  13. result = is_prime('2')    
  14. print(result)   
Using a print statement to check the input value might be a bit confusing as it may look like a number. This function is a very simple example which may not use require such critical analysis but it is useful to understand the ways to debug issues in general.
 
To make good use of debugging, Python comes with the built-in module pdb. It provides a lot of helpful methods for debugging such as set_trace()
 
main.py
  1. import pdb    
  2.     
  3. def is_prime(num):    
  4.     pdb.set_trace()    
  5.     if num > 1:    
  6.         for i in range(2, num):    
  7.             if (num % i) == 0:    
  8.                 return False    
  9.         else:    
  10.             return True    
  11.     
  12.     else:    
  13.         return False    
  14.     
  15. result = is_prime('2')    
  16. print(result)    
On running the program, the interpreter pauses the program at the place where set_trace() is called. Now in the debugging console, we can type in any variable whose value we want to check in that point of execution such as num in this case. It will immediately show the value as ‘2’ which is a string. Hence we can troubleshoot that.
 
From Python 3.7 onwards, there is a better way to debug with a new method breakpoint which automatically calls the set_trace method under the hood and is the recommended way to debug.
 
main.py
  1. def is_prime(num):    
  2.     breakpoint() # places a breakpoint    
  3.     if num > 1:    
  4.         for i in range(2, num):    
  5.             if (num % i) == 0:    
  6.                 return False    
  7.         else:    
  8.             return True    
  9.     
  10.     else:    
  11.         return False    
  12.     
  13. result = is_prime('2')    
  14. print(result)   
Now there are a lot of debugging commands available in the pdb console. Just typing help provides a list of available commands. I would like to provide a list of them here for reference.
 
Command Description
p Print the value of an expression.
pp Pretty-print the value of an expression.
n Continue execution until the next line in the current function is reached or it returns.
s Execute the current line and stop at the first possible occasion (either in a function that is called or in the current function).
c Continue execution and only stop when a breakpoint is encountered.
unt Continue execution until the line with a number greater than the current one is reached. With a line number argument, continue execution until a line with a number greater or equal to that is reached.
l List source code for the current file. Without arguments, list 11 lines around the current line or continue the previous listing.
ll List the whole source code for the current function or frame.
b With no arguments, list all breaks. With a line number argument, set a breakpoint at this line in the current file.
w Print a stack trace, with the most recent frame at the bottom. An arrow indicates the current frame, which determines the context of most commands.
u Move the current frame count (default one) levels up in the stack trace (to an older frame).
d Move the current frame count (default one) levels down in the stack trace (to a newer frame).
h See a list of available commands.
h <topic> Show help for a command or topic.
h pdb Show the full pdb documentation.
q Quit the debugger and exit.
 
Debugging Resources
  • https://realpython.com/python-debugging-pdb/
  • https://book.pythontips.com/en/latest/debugging.html

Unit Testing

 
Our IDEs and Editors come equipped with lot of tooling capabilities that assist us in writing better code with fewer mistakes using a linter such as pylint for example. We can also debug our code to check for possible causes of errors. However a more reliable and efficient programming principle is to write defensive code by writing unit tests which ensures our programs run under different practical scenarios and edge cases.
 
Writing tests often sound boring and might appear intimidating at first. However they are extremely useful and saves a lot of time and effort in the long run by preventing lot of unprecedented bugs. It also actually improves our code as well serve as a great documentation. For programmers, it is far easier and more practical to read small simple unit tests to understand the functionalities rather than go through a long list of documentation. It is always great to know how to write good and simple tests for our programs.
 
Python again provides out of the box support for unit testing using a built-in module unittest. It is also called a test-runner which can multiple tests all at once for the entire project.
 
Let’s try to test the above is_prime function by writing some simple tests. For that, we need to create a test file which in this case will be test.py
 
test.py
  1. import unittest    
  2. import main    
  3.     
  4. class TestPrime(unittest.TestCase):    
  5.   def test_valid_type(self):    
  6.     test_input = 13    
  7.     test_result = main.is_prime(test_input)    
  8.     expected_result = True    
  9.     self.assertEqual(test_result, expected_result)    
  10.     
  11. if(__name__ == '__main__'):    
  12.   unittest.main()    
The unit tests are written as a Python class and each test case scenario is written as a separate method inside the class. The class needs to extend the unittest.TestCase class. Also at the end there is a check to ensure the unit tests are initiated only if it runs from the main module.
 
The test file can then be run using python3 test.py or python3 test.py based on your python setup. The above test should pass as the test scenario meets the function criteria.
Let’s try to fail the test by adding a new scenario. We expect the function to return false if the provided input is not a valid input.
 
test.py
  1. import unittest    
  2. import main    
  3.     
  4. class TestPrime(unittest.TestCase):    
  5.     def test_valid_type(self):    
  6.         test_input = 13    
  7.         test_result = main.is_prime(test_input)    
  8.         expected_result = True    
  9.         self.assertEqual(test_result, expected_result)    
  10.     
  11.     def test_invalid_input(self):    
  12.         test_input = 'hello'    
  13.         test_result = main.is_prime(test_input)    
  14.         expected_result = False    
  15.         self.assertEqual(test_result, expected_result)    
  16.     
  17. if (__name__ == '__main__'):    
  18.     unittest.main()  
In this case the second test fails as we haven’t handled the possibility of a TypeError in the function. So the function can be modified accordingly.
 
main.py
  1. def is_prime(num):    
  2.     if (not isinstance(num, int)):    
  3.         return False    
  4.     if num > 1:    
  5.         for i in range(2, num):    
  6.             if (num % i) == 0:    
  7.                 return False    
  8.             else:    
  9.                 return True    
  10.     
  11.     else:    
  12.         return False   
Now the function handles invalid inputs and will not break in that scenario. Let’s add some more test cases. Another approach would be to put the block of code in a try except block and handle all possible exceptions there.
 
test.py
  1. import unittest    
  2. import main    
  3.     
  4. class TestPrime(unittest.TestCase):    
  5.     def test_valid_type(self):    
  6.         test_input = 13    
  7.         test_result = main.is_prime(test_input)    
  8.         expected_result = True    
  9.         self.assertEqual(test_result, expected_result)    
  10.     
  11.     def test_invalid_input(self):    
  12.         test_input = 'hello'    
  13.         test_result = main.is_prime(test_input)    
  14.         expected_result = False    
  15.         self.assertEqual(test_result, expected_result)    
  16.         
  17.     def test_none_input(self):    
  18.         test_input= None    
  19.         test_result = main.is_prime(test_input)    
  20.         expected_result = False    
  21.         self.assertEqual(test_result, expected_result)    
  22.     
  23.     def test_negative_input(self):    
  24.         test_input= -13    
  25.         test_result = main.is_prime(test_input)    
  26.         expected_result = False    
  27.         self.assertEqual(test_result, expected_result)    
  28.     
  29. if (__name__ == '__main__'):    
  30.     unittest.main()   
If we want to initialize some variables or set up some configuration before running the tests, it can be written in the setup method. Similarly any kind of clean up after each test can be done in the teardown method. The setUp method is mostly is used more often than the tearDown method.
  1. import unittest    
  2. import main    
  3.     
  4. class TestPrime(unittest.TestCase):    
  5.     def setUp(self):    
  6.         print('This will run before each test')    
  7.     
  8.     def test_valid_type(self):    
  9.         test_input = 13    
  10.         test_result = main.is_prime(test_input)    
  11.         expected_result = True    
  12.         self.assertEqual(test_result, expected_result)    
  13.     
  14.     def test_invalid_input(self):    
  15.         test_input = 'hello'    
  16.         test_result = main.is_prime(test_input)    
  17.         expected_result = False    
  18.         self.assertEqual(test_result, expected_result)    
  19.     
  20.     def test_none_input(self):    
  21.         test_input = None    
  22.         test_result = main.is_prime(test_input)    
  23.         expected_result = False    
  24.         self.assertEqual(test_result, expected_result)    
  25.     
  26.     def test_negative_input(self):    
  27.         test_input = -13    
  28.         test_result = main.is_prime(test_input)    
  29.         expected_result = False    
  30.         self.assertEqual(test_result, expected_result)    
  31.     
  32.     def tearDown(self):    
  33.         print('this will run after each test')    
  34.     
  35. if (__name__ == '__main__'):    
  36.     unittest.main()    
This is how unit tests can help us to improve our code and make sure our code doesn’t break under different scenarios. Also it ensures that a newly introduced feature doesn’t break existing features.
 
Here are some great resources to understand and explore more on unit testing in Python.
  • https://www.freecodecamp.org/news/an-introduction-to-testing-in-python/
  • https://www.geeksforgeeks.org/unit-testing-python-unittest/
  • https://www.datacamp.com/community/tutorials/unit-testing-python
  • https://docs.python.org/3/library/unittest.html (Official Docs)
  • Setup Tests in PyCharm (Video)
  • Unit Testing in VSCode
I hope I was able to explain in brief the benefits and use cases of debugging and testing Python code. The more we start testing and debugging, the more we start knowing about the language and write better code.
 
That’s all for today. Tomorrow I’ll be exploring how to do automated testing with Python using Selenium.
 
Have a great one!


Recommended Free Ebook
Similar Articles