Python

Python Unit Testing - What's the Point?

Python Unit Testing - What's the Point?
In: Python, NetDevOps

When I first started to learn about unit testing in Python, I found tons of articles on how to use unit testing but very few on why we even need it. So, I decided to dive into the world of unit testing and share my journey through this blog post.

I've been using Python for the last 3-4 years but, confession time, I hadn't written a single unit test until recently (please don't judge me for this). I honestly thought I didn’t need them. My code worked, and on the rare occasions when my colleagues or I made changes, it was our responsibility to ensure nothing broke. We relied on manual testing to catch any issues, which seemed sufficient at the time.

Fast forward to now, and the landscape has changed. Our codebase has grown significantly, and with every change, we found ourselves needing to test more and more. It became clear that our old methods weren't cutting it anymore. If you're reading this and thinking you can't relate, hold on. Once we dive into the examples, I promise it will start to make sense.

In this post, we will cover the following.

  1. What is Unit Testing in Python?
  2. Unittest vs Pytest
  3. A Simple Example using unittest
  4. Pytest

What Exactly is Unit Testing?

Unit testing is like giving each small piece of your code a mini-exam to make sure it knows its stuff before it gets to play its part in the bigger picture of your application. It focuses on testing individual units of code, usually functions or methods, to ensure they work exactly as expected.

Why do we need it, though? Imagine building a puzzle. You wouldn't want to find out that a piece doesn't fit after you've almost completed the puzzle. Similarly, unit testing helps us catch mistakes early in the development process before they can cause bigger problems down the line. It’s about making sure each building block of your code is error-free and behaves as intended.

Unittest vs Pytest

unittest is the built-in testing framework that comes with Python. It's inspired by JUnit, a Java testing framework. unittest is great if you prefer a more traditional, object-oriented way of writing tests. You write a small test for a piece of your code, and unittest runs that test to see if the code does what you expect. If everything's good, it tells you your code passed the test.

pytest is a third-party framework that has gained popularity for its simplicity and ease of use. pytest allows you to write test codes using Python's assert statement, making tests easier to read and write. It supports fixtures, which can be used to manage test dependencies, state, or input/output, and it can run unittest tests. pytest is often praised for its powerful features, like support for parameterized testing, plugins, and its ability to run tests written for unittest.

Managing Multiple Python Versions with pyenv
Pyenv is a tool that helps you manage multiple Python versions on a single machine. You simply choose which version you want to use for each project, and pyenv handles the rest.

A Simple Python Unit Testing Example

Let's dive into the heart of our discussion with a practical example, the price_calculator function. Our goal here is simple – we want to make sure that this function always calculates the price correctly based on the number of items passed to it.

Our function to test

def price_calculator(items):
    if items <= 0:
        return 0 
    elif 1 <= items <= 10:
        price_per_item = 10
    else:  # This implicitly covers the case where items >= 11
        price_per_item = 9
    return items * price_per_item
  • If someone buys between 1 to 10 items, each item costs £10.
  • For purchases of more than 10 items, the price per item drops to £9
  • If no items are bought, or a negative number is somehow provided, the total price is £0

When we first write a function like this, we might manually test it by entering some numbers to see if we get the expected price. This works fine when the code is fresh and our memory of it is sharp. But imagine a scenario where multiple people are working on the same code. Not everyone might understand the original reasoning behind the code or they might want to improve it. The key is they want to ensure any changes don't mess up the core functionality – which, in this case, is calculating the price based on the number of items.

Of course, they could manually test it each time they tweak the code, but there's a smarter way to do this - Unit Testing.

Test file

This leads us to our example of unit testing using Python's unittest framework. For this to work, imagine we have both the price_calculator function and our unit tests in the same directory. The file with our function might simply be named price_calculator.py, and our test file is called test_price_calculator.py. Here's what the directory and test file look like.

➜  basic_test tree
.
├── price_calculator.py
└── test_price_calculator.py

1 directory, 2 files
import unittest
from price_calculator import price_calculator

class TestPriceCalculator(unittest.TestCase):
    def test_zero_or_negative(self):
        self.assertEqual(price_calculator(0), 0)
        self.assertEqual(price_calculator(-5), 0)

    def test_single(self):
        self.assertEqual(price_calculator(1), 10)

    def test_one_ten(self):
        self.assertEqual(price_calculator(5), 50)
    
    def test_ten(self):
        self.assertEqual(price_calculator(10), 100)
    
    def test_eleven(self):
        self.assertEqual(price_calculator(11), 99)
    
    def test_over_twenty(self):
        self.assertEqual(price_calculator(20), 180)

if __name__ == '__main__':
    unittest.main()

First, we import the necessary modules – unittest for the testing framework and price_calculator from our function file. This setup allows us to use the testing tools and the function we want to test within the same test file. The core of our test file is a class named TestPriceCalculator that inherits from unittest.TestCase. This inheritance is crucial because it gives our class the ability to run tests using all the methods and tools provided by the unittest framework.

Inside this class, we define several methods, each starting with the word test This naming convention is important because unittest looks for any method in the TestCase subclass that starts with test to identify it as a test method to run. Each method tests a specific scenario for our price_calculator function, such as handling zero or negative inputs, calculating the price for a single item, and checking the price for various numbers of items.

We use self.assertEqual to perform the actual tests. This method checks if the first argument (the result of calling our price_calculator function with specific inputs) matches the second argument (the expected outcome). If they match, the test passes; if not, it fails, indicating a problem in our function's logic.

Finally, the if __name__ == '__main__': unittest.main() part tells Python to run the tests in the file if it's executed as the main program. This means that if we run the test file directly, it will execute all the test methods we've defined, automatically reporting the results.

This approach ensures that no matter who tweaks the code or how it's modified, we can quickly check if the price_calculator still does its job right. By running these tests, we get immediate feedback if a change breaks the functionality, making our coding process more efficient and reliable.

Testing and verification

When we run our tests using the command python test_price_calculator.py, the output we see is a concise report from the unittest framework, showing the results of our tests. Here's a breakdown of what this output means.

➜  basic_test python test_price_calculator.py
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

...... Each dot represents a single test that has passed. In this case, we see six dots, meaning all six tests we wrote in our test_price_calculator.py file have passed successfully. This visual feedback is a quick way to see how many tests were run and whether they passed without diving into the details.

Ran 6 tests in 0.000sthis part tells us how many tests were run and how long it took to run them. In this case, we ran six tests, and they executed very quickly—in less than a millisecond.

OK This is the final verdict of our test run. "OK" means that all tests passed successfully. If any tests had failed, instead of "OK," we would see a summary of the failed tests, including which tests failed and why.

Failure scenario

In this scenario, let's say I revisited my code after a few months and stupid me decided to remove the '=' sign from the condition elif 1 <= items <= 10 in the price_calculator function, changing it to elif 1 <= items < 10. This change means that the function now treats 10 items as if they should be priced at £9 per item, not £10 as originally intended.

After making this change and rerunning the tests, this is the output we get.

➜  basic_test python test_price_calculator.py
....F.
======================================================================
FAIL: test_ten (__main__.TestPriceCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "//Documents/python/pytest_example/basic_test/test_price_calculator.py", line 16, in test_ten
    self.assertEqual(price_calculator(10), 100)
AssertionError: 90 != 100

----------------------------------------------------------------------
Ran 6 tests in 0.000s

FAILED (failures=1)

Here's what happened

  • ....F. The dots still represent passing tests, but now there's an 'F' among them, indicating that one test has failed. This tells us that one of our scenarios didn't behave as expected after the recent code change.
  • FAIL: test_ten (__main__.TestPriceCalculator) This line specifies which test failed — the test_ten method in our TestPriceCalculator class. This method was checking if 10 items cost £100, based on the original pricing logic (£10 per item for up to 10 items).
  • AssertionError: 90 != 100 The test expected the function to return £100 for 10 items, but after the change, the function returned £90 instead (10 items at £9 each). This mismatch caused the test to fail.
  • Ran 6 tests in 0.000s This part, as before, tells us how many tests were run and how quickly.
  • FAILED (failures=1) The summary now indicates that the test run was not successful due to one failure.

This outcome is a perfect illustration of one of the key benefits of having automated tests. Even a seemingly minor change to the code, like removing an '=' sign, can have unexpected consequences.

💡
Please note that it's also key that we don't end up writing too many tests. There's a balance to strike. For our price_calculator, I'm focusing on essential cases like zero, 9, 10, 11 and 20 items. This approach helps us ensure the function behaves as expected in these critical scenarios without overwhelming ourselves with an unnecessary number of tests.

Without automated tests, this kind of error might slip through unnoticed, potentially leading to incorrect functionality in production. By catching the mistake early, you can investigate, understand the implications of your change, and correct it before it affects anything else.

Pytest

Pytest is an external module for Python, which means it's not included with the standard library like unittest. To use pytest, you'll need to install it first, which you can do easily using pip, Python's package installer. Just run pip install pytest in your command line, and you're good to go.

Pytest offers a simpler syntax for writing tests compared to unittest, and it's designed to be more Pythonic. Pytest has a more straightforward assertion model that allows you to use the plain assert statement instead of special self.assert* methods. Pytest also has some powerful features like fixtures and markers, which make it easy to write more complex tests.

Using the previous example with pytest

Even though pytest is a separate tool, one of its neat features is its compatibility with unittest. This means you can write your tests using the unittest framework's structure, but still run them using the pytest command.

Here, the first output indicates that all the tests passed when I ran them with pytest. Then, similar to what we discussed earlier with unittest, after I removed the '=' sign, one of the tests failed. The output from pytest is visually different but conveys the same information: which test failed and why.

How to write pytests?

Writing tests with pytest is refreshingly straightforward, and here's a glimpse of how it's done with an example file named test_pytest_example.py. This file contains a set of functions that will test the price_calculator function, ensuring it calculates prices accurately.

from price_calculator import price_calculator

def test_zero_negative():
    assert 0 == price_calculator(0)
    assert 0 == price_calculator(-5)

def test_single():
    assert 10 == price_calculator(1)

def test_one_ten():
    assert 50 == price_calculator(5)

def test_ten():
    assert 100 == price_calculator(10)

def test_eleven():
    assert 99 == price_calculator(11)

def test_over_twenty():
    assert 180 == price_calculator(20)

In pytest, each test is a simple function defined with the name starting with test_ This naming is essential because pytest automatically detects any functions that start with test_ as individual tests to execute.

Within each test function, we use plain assert statements to verify the behaviour of our price_calculator. The assert statement checks if the expression to its right is true. If it's true, the test passes, and if it's not, pytest flags the test as a failure and provides output detailing what went wrong.

Each test function tests a different scenario. test_single() checks the case of a single item, test_one_ten() checks for a mid-range number of items, test_ten() ensures that ten items are priced correctly, and test_eleven() and test_over_twenty() check the pricing at and beyond our bulk discount threshold. If we were to intentionally fail one of the tests, this would be the output.

Closing Thoughts

In conclusion, unit testing in Python, whether with unittest or pytest, is an invaluable practice that can save you time, improve your code quality, and provide peace of mind. We've seen how simple it is to get started with both frameworks and how they help catch issues early, making sure your code does exactly what you expect it to do.

Moving forward, I encourage you to incorporate unit testing into your regular coding routine. It might seem like extra effort at first, but the payoff in code reliability and the ease of future changes is well worth it.

Written by
Suresh Vina
Tech enthusiast sharing Networking, Cloud & Automation insights. Join me in a welcoming space to learn & grow with simplicity and practicality.
Comments
More from Packetswitch
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.