Python Unit Testing - What's the Point?
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.
- What is Unit Testing in Python?
- Unittest vs Pytest
- A Simple Example using unittest
- 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
.
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.000s
this 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 — thetest_ten
method in ourTestPriceCalculator
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.
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.