Best Practices for Writing Effective Unit Tests in Python

Best Practices for Writing Effective Unit Tests in Python

Testing is a fundamental aspect of software development. Understanding the best practices for writing unit tests is crucial for any programmer, whether you're just starting or have a bit of coding experience under your belt. This Article is tailored to meet the needs of both newcomers and beginner Python developers. By delving into the world of testing excellence, you'll soon discover how to elevate your code's reliability and maintainability, setting the stage for successful Python projects ahead.

This article explores the fundamentals of unit testing in Python, dissects the principles of Test-Driven Development (TDD), and the pros and cons of TDD. This article will also guide you on how to structure and name your test files and will introduce you to the unittest module in Python that can help you craft unit tests that not only verify your code but also elevate its reliability and maintainability.

Importance of Tests in Software Development

The importance of tests to software development cannot be overstated, The following states a few of the importance of writing tests in software development:

  • Writing tests helps you detect bugs early.

  • It ensures your code reaches the required standard.

  • Tests serve as living code documentation.

  • It helps you to safely refactor your code.

  • It gives you confidence when deploying your code.

  • It enhances users' experience

Test-Driven Development

Test-Driven Development(TDD) is a term coined in 2002 by Kent Beck in his book "Test Driven Development". The core idea of TDD is that you write your tests before you write your actual code. TDD follows a cycle commonly known as the "Red-Green-Refactor" cycle, which consists of five main steps:

  1. Write the Tests.

  2. Run the tests and make sure they all fail.

  3. Write the actual code.

  4. Run the tests and make sure they all pass.

  5. Refactor and improve your code.

This cycle repeats for each piece of functionality or code you're working on. TDD encourages small, incremental steps and continuous testing throughout the development process.

The examples in this article follow the Test-Driven Development cycle which is otherwise known as the Red-Green Refactor.

Pros and Cons of Test-Driven Development

  • TDD encourages the creation of explicit test cases before writing code, ensuring a clear understanding of what needs to be achieved.

  • TDD allows for the early identification and resolution of issues during development, which helps maintain the reliability of your software.

  • When a test fails, it provides immediate guidance on which part of the code needs attention, simplifying the debugging process.

  • Test cases serve as dynamic documentation, offering insights into the intended behaviour of the code for both current and future developers.

  • TDD instils confidence when making modifications or refactoring, knowing that existing tests will quickly detect any unintended side effects.

While TDD has its benefits, you should also know that it may take some time to adapt fully, and it may not be suitable for every project. However, for critical systems and long-term projects, TDD can greatly enhance code quality, maintainability, and reliability.

As with every other methodology, TDD also has its CONS, Here are a few of them:

  • Writing and maintaining lots of tests take time.

  • Often The developer that writes the code, writes the test, which may lead to blind spots.

Test Files Structuring and Their Naming Convention

In the process of software development, structuring your files and folders is very important and cannot be over-emphasized. Just like other files and folders you create or use in development, Test files also have their file structuring and naming convention. Here are the following criteria you should follow when creating a Test file or folder:

  • Place test files separate from the main code files to maintain a clear distinction between production code and testing code.

  • In the case of large projects, Create a dedicated folder for your tests. Common names for this folder include "Tests," "test_suite," or "test."

  • Organize your test modules to mimic the structure of your production code. If your project has submodules or packages, create corresponding test submodules/packages within your testing folder.

  • Follow a consistent naming convention for your test files. A more common convention is to prefix your test files with "test_", followed by the name of the module or class being tested. For example, if you have a module named my_module.py, the corresponding test file could be named test_my_module.py.

  • For unit tests within a test file, use descriptive names that indicate what aspect of the code is being tested. Names like test_function_behavior or test_method_scenario provide clarity.

  • If you're using test classes (e.g., with the unittest framework), follow the convention of naming test classes with a "Test" suffix. For example, if you're testing a class named MyClass, the corresponding test class could be named TestMyClass.

Great! Now that you know how to organise and structure Test files, Let's delve into the concept of unit tests.

Python Unittest Module

In software development, a unit test is a type of test that focuses on verifying the correctness of individual components or units of code in isolation. A unit typically refers to the smallest testable part of an application, such as a function, method, or class method. The primary goal of unit testing is to ensure that each unit of code behaves as expected when given specific inputs and produces the correct output.

This chapter of this article will explore how to use the built-in Python unittest module to write good and effective unit tests, best practices that also adhere to TDD and how to set up and tear down your tests after you run them.

For example, you want to create a Python scriptCalculate/calc.py, which will perform basic mathematical operations (Addition, Multiplication, Subtraction and Division) on two numbers.

#!/usr/bin/python3

def add(a: int, b:int):
    """
    adds two integers
    """
    pass

def multiply(a: int, b:int):
    """
    multiplies two integers
    """
    pass

def subtract(a: int, b:int):
    """
    subtracts b from a
    """
    pass

def divide(a: int, b:int):
    """
    divides a by b
    """
    pass

Ps - note that the type hint passed to parameters a and b does not change the functionality of the code in any way. It is called type Annotation in Python. it only means that the function takes in two integer parameters and returns an integer. To learn more about the concept of Type Annotation, you can refer to my blog How to Boost your Python Programming Skills with Type Annotation

Now, To Follow the TDD Methodology, you will first have to think of various edge cases that each of the functions can have and write tests to meet those requirements.

The following are natural basic edge cases you might think of:

  • The functions must take two numbers as their parameters.

  • The functions should handle two positive integers.

  • The functions should be able to handle one positive and one negative integer

  • The functions should handle two negative integers.

Now create a test file test_calc.py in the same calculate folder and write the test cases:

import unittest
import calc

class TestCalc(unittest.TestCase):
    """
    Tests for the common edge cases
    of the function in calc app
    """

    def test_add(self):
        """
        Tests the add function
        """
        with self.assertRaises(TypeError):
            calc.add(3)
        self.assertEqual(calc.add(10, 5), 15)
        self.assertEqual(calc.add(-1, 1), 0)
        self.assertEqual(calc.add(-10, -5),- 15)

    def test_multiply(self):
        """
        Tests the multiply function
        """
        with self.assertRaises(TypeError):
            calc.multiply(3)
        self.assertEqual(calc.multiply(10, 5), 50)
        self.assertEqual(calc.multiply(-1, 1), -1)
        self.assertEqual(calc.multiply(-10, -5), 50)

    def test_subtract(self):
        """
        Tests the subtract funtion
        """
        with self.assertRaises(TypeError):
            calc.subtract(3)
        self.assertEqual(calc.subtract(10, 5), 5)
        self.assertEqual(calc.subtract(-1, 1), -2)
        self.assertEqual(calc.subtract(-10, -5), -5)

    def test_divide(self):
        """
        Tests the divide function
        """
        with self.assertRaises(TypeError):
            calc.divide(3)
        self.assertEqual(calc.divide(10, 5), 2)
        self.assertEqual(calc.divide(-1, 1), -1)
        self.assertEqual(calc.divide(-10, -5), 2)

In this test_calc.py test case, you import the Python unittest module, which is a Python built-in function used to write unit tests. Next, you import your calc module(learn more on how to import Python modules). You then create a class TestCalc ( you can use any name but it is best to keep your naming descriptive) which inherits from the unittest.TestCase module which gives you access to many testing capabilities within that class.

Just like any method in a class, the functions take self as its first argument, the tests are then written within the method. Now since your class inherits from unittest

The tests are written to meet the basic requirements that were listed. Now the next step is to run the code and make sure that all tests fail. To run the tests you go to your terminal and run the command python3 -m unittest test_calc.py

hashnode@learning:~$ python3 -m unittest test_calc.py
FFFF
======================================================================
FAIL: test_add (test_calc.TestCalc)
Tests the add function
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/test_calc.py", line 19, in test_add
    self.assertEqual(calc.add(10, 5), 15)
AssertionError: None != 15

======================================================================
FAIL: test_divide (test_calc.TestCalc)
Tests the divide function
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/test_calc.py", line 49, in test_divide
    self.assertEqual(calc.divide(10, 5), 2)
AssertionError: None != 2

======================================================================
FAIL: test_multiply (test_calc.TestCalc)
Tests the multiply function
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/test_calc.py", line 29, in test_multiply
    self.assertEqual(calc.multiply(10, 5), 50)
AssertionError: None != 50

======================================================================
FAIL: test_subtract (test_calc.TestCalc)
Tests the subtract funtion
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/test_calc.py", line 39, in test_subtract
    self.assertEqual(calc.subtract(10, 5), 5)
AssertionError: None != 5

----------------------------------------------------------------------
Ran 4 tests in 0.037s

FAILED (failures=4)

Great! now all the tests have failed. Note that it shows that it ran 4 tests even though in all there were 12 test edge cases three in each test function. This shows that unittest considers the edge cases under each test function as one test. This is to say that the aim is not to write many tests but to write tests to catch any edge cases.

That said, Now you move to the next phase which is to write the code. First, begin by implementing the basic requirements that were listed.

 #!/usr/bin/python3

def add(a: int, b: int):
    """ adds two integers """
    return a+b

def multiply(a: int, b: int):
    """ multiplies two integers """
    return a * b

def subtract(a: int, b: int):
    """ subtracts b from a """
    return a - b

def divide(a: int, b: int):
    """ divides a by b """
    return a / b

Now that you have implemented the basic requirements, run the test again with the command python3 -m unittest test_calc.py .

....
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK

Congratulations, now you are in the Green phase of the Red-Green Refactor. Now that the tests pass, you have an environment where you are sure that your code adheres to your specifications. You can now move around things and change things and make sure that they still work. For instance if you want your functions to take and return only integer values, you can add a conditional and also type hint your function for better documentation. you can also add a conditional statement to the divide function that raises a value error if b is equal to zero.

#!/usr/bin/python3

def add(a: int, b: int) -> int:
    """ adds two integers """
    if not isinstance(a, (int)) or not isinstance(b, (int)):
        raise TypeError("Both inputs must be integers")
    return a+b

def multiply(a: int, b: int) -> int:
    """ multiplies two integers """
    if not isinstance(a, (int)) or not isinstance(b, (int)):
        raise TypeError("Both inputs must be integers")
    return a * b

def subtract(a: int, b: int) -> int:
    """ subtracts b from a """
    if not isinstance(a, (int)) or not isinstance(b, (int)):
        raise TypeError("Both inputs must be integers")
    return a - b

def divide(a: int, b: int) -> int:
    """ divides a by b """
    if not isinstance(a, (int)) or not isinstance(b, (int)):
        raise TypeError("Both inputs must be integers")
    if b == 0:
        raise ValueError('Is not divisible by zero')
    return a / b

Good, now that you have refactored your code, you run it again and make sure it still adheres to the specifications and that all your previous test passes.

....
----------------------------------------------------------------------
Ran 4 tests in 0.021s

OK

Now you can repeat the TDD process and write tests to capture any edge cases you can think of based on your code's specifications until you are sure that you have covered very edge case. Here are some ideas:

  • Test with inputs where one or both of the integers are zero to ensure that the function handles zero values correctly.

  • Check for integer overflow between two very large positive integers that exceed the maximum value of an integer.

  • Similarly, check for integer underflow between two very large negative integers that go below the minimum value of an integer.

  • Test with very large integers (both positive and negative) to ensure that the function can handle large numbers efficiently.

  • Test with non-numeric inputs, like lists, dictionaries, or None, to ensure that the function gracefully handles invalid input types.

Conclusion

In conclusion, mastering the art of unit testing and embracing TDD is a pivotal journey for any Python programmer, whether a newcomer or someone with some coding experience. As you've seen throughout this article, testing is not merely a necessary chore but a powerful tool that can elevate your code to new heights of reliability and maintainability.

We've explored the critical role that testing plays in software development, emphasizing its ability to catch bugs early, maintain code quality, serve as living documentation, and instil confidence when deploying code. These benefits, combined with the disciplined approach of TDD, can reshape the way you approach and create software, ensuring that your Python projects are built on a solid foundation.

Moreover, we delved into the practical aspects of writing unit tests, discovering the importance of structuring test files and naming conventions. We've seen how Python's built-in unittest module can be employed to create effective tests that conform to TDD principles.

As you venture into the world of Python development, remember that testing is not just an afterthought or a luxury; it's a fundamental practice that separates good code from great code. It empowers you to write more robust, error-resistant software, and as you continue to hone your skills, you'll find that testing becomes second nature, a reliable companion on your journey to becoming a proficient Python developer.

With a strong understanding of unit testing, the Test-Driven Development mindset, and the ability to create and execute effective tests, you're well-equipped to embark on a path of coding excellence, where your Python projects will thrive in the face of complexity, change, and growth. So go ahead, put your knowledge into practice, and let testing lead the way to your software development success. Happy coding!