Unit Testing
Last updated on 2026-06-26 | Edit this page
Estimated time: 12 minutes
Overview
Questions
- What is unit testing?
- Why is unit testing important?
- How do you write a unit test in Python?
Objectives
- Explain the concept of unit testing
- Demonstrate how to write and run unit tests in Python using
pytest
Unit Testing
We have our little test files, but you might imagine that it’s not particularly efficient to always write individual scripts to test our code. What if we had a lot of functions or classes? Or a lot of different ideas to test? What if our objects changed down the line? Our existing modules are going to be difficult to maintain, and, as you may have expected, there is already a solution for this problem.
What Makes For a Good Test?
A good test is one that is: - Isolated: A good test should be able to run independently of other tests, and should not rely on external resources such as databases or web services. - Repeatable: A good test should produce the same results every time it is run. - Fast: A good test should run quickly, so that it can be run frequently during development. - Clear: A good test should be easy to understand, so that other developers can easily see what is being tested and why. Not just in the output provided by the test, but the variables, function names, and structure of the test should be clear and descriptive.
It can be easy to get carried away with testing everything and writing tests that cover every single case that you can come up with. However having a massive test suite that is cumbersome to update and takes a long time to run is not useful. A good rule of thumb is to focus on testing the most important parts of your code, and the parts that are most likely to break. This often means focusing on edge cases and error handling, rather than trying to test every possible input.
Generally, a good test module will test the basic functionality of an object or function, as well as a few edge cases.
What are some edge cases that you can think of for the
honk_horn function we wrote earlier?
What about the calculate_liters_per_100km function?
Some edge cases for the honk_horn function could
include:
- The user provides a negative number for
times - The user provides a non-integer value for
times - The user provides a very large number for
times
Some edge cases for the calculate_liters_per_100km
function could include:
- Passing in a distance of zero
- Passing in a negative distance
- Passing in a negative amount of liters
- Passing in non-numeric values for either parameter
pytest
pytest is a testing framework for Python that helps to
write simple and scalable test cases. It is widely used in the python
community, and has in-depth and comprehensive documentation. We won’t be
getting into all of the different things you can do with pytest, but we
will cover the basics here.
To start off with, we need to add pytest to our environment. However
unlike our previous packages, pytest is not required for
our module to work, it is only used by us as we are writing code.
Therefore it is a “development dependency”. We can still add this to our
pyproject.toml file via uv, but we need to add a special
flag to our command so that it goes in the correct place.
If you open up your pyproject.toml file, you should see
that pytest has been added under a new called “dependency
groups” (your version number may be different):
Now we can start creating our tests.
Writing a pytest Test File
Part of pytest is the concept of “test discovery”. This
means that pytest will automatically find any test files that follow a
certain naming convention. By default, pytest will look for files that
start with test_ or end with _test.py. Inside
these files, pytest will look for functions that start with
test_.
Our test file doesn’t start with test_. There is a way
to change the naming convention that pytest uses, but for now, let’s
just rename our test file to test_vehicle_module.py.
Next, let’s create a test for our honk_horn function
based on the one we wrote before. Add a function at the top of the file
like this
PYTHON
import sys
sys.path.insert(0, "./src")
import vehicle_module
def test_honk_horn():
assert vehicle_module.horn_noises.honk_horn(2) == "Honk! Honk! "
pytest uses the assert statement to check
if the output of a function is what we expect. If the assertion is True,
then the test passes. If the assertion is False, then the test fails.
Tests can have multiple assertions, and all of them need to be True for
the test to pass.
In our previous test file, we had to add the path to our module each
time. Now that we are using pytest, we can use a special
file called conftest.py to add this path automatically.
Create a file called conftest.py in the tests
directory and add the following code to it:
Then we can remove the sys.path.insert line from our
test file, and just have the import statement:
PYTHON
import vehicle_module
def test_honk_horn():
assert vehicle_module.honk_horn(2) == "Honk! Honk! "
This is how I always do it, but I assume there’s a better way.
Now, we just need to run the tests. We can do this with the following command:
Note that we are using uv run to run
pytest, this ensures that pytest is run in the
correct environment with all the dependencies we have installed.
You should see output similar to the following:
============================= test session starts ==============================
platform win32 -- Python 3.13.5, pytest-9.1.1, pluggy-1.6.0
rootdir: E:\Documents\Projects\vehicle-module
configfile: pyproject.toml
collected 1 item
tests\test_vehicle_module.py . [100%]
============================== 1 passed in 0.03s ===============================
Why didn’t it run the other test code? Because the need to be in
functions that start with test_. This is part of the test
discovery process that pytest uses. If we want to test other functions,
we can add more functions.
Let’s convert the rest of our tests to pytest tests. The
updated test file should look like this:
PYTHON
import vehicle_module
def test_honk_horn():
assert vehicle_module.horn_noises.honk_horn(2) == "Honk! Honk! "
def test_play_engine_sound():
assert (
vehicle_module.engine_noise.play_engine_sound(3000) == "Honk! \nVroom! Engine at 3000 RPM"
)
def test_calculate_liters_per_100km():
assert vehicle_module.efficiency.fuel.calculate_liters_per_100km(km=50, liters=2.5) == 5.0
Let’s run our tests again:
You should see output similar to the following:
============================= test session starts ==============================
platform win32 -- Python 3.13.7, pytest-8.4.2, pluggy-1.6.0
rootdir: E:\Documents\Projects\vehicle-module
configfile: pyproject.toml
collected 4 items
tests\test_vehicle_module.py ... [100%]
============================== 3 passed in 0.15s ===============================
You can see that all of the tests have passed. There is a small green
pip for each test that was performed, and a summary at the end. Compare
this to the test file we had before. We got rid of all of the if
statements, and just use the assert statement to check if
the output is what we expect.
Testing Exceptions
We usually want to test the most basic use case of our functions, but
maybe we want to also check for some common Edge Cases - situations that
are not necessarily expected, but that we want to anticipate and handle
in our code. For example, what if the user provides a value of 0 for the
times parameter in the honk_horn. What should
happen?
After our test_honk_horn function, add the following
test:
Run the tests again:
We get passing tests, which is what we expect. But we are the ones in
charge of the function, what if we say that if the user provides a value
of 0 or less, we want the function to raise a ValueError?
We can write a test that checks not only for the output of the function,
but also for which exception it raises. Update the
test_honk_horn_zero function to the following:
PYTHON
import pytest
# Existing code
def test_honk_horn_zero():
with pytest.raises(ValueError):
vehicle_module.horn_noises.honk_horn(0)
Run the tests again:
================================= FAILURES =====================================
_____________________________ test_honk_horn_zero ______________________________
def test_honk_horn_zero():
> with pytest.raises(ValueError):
^^^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE ValueError
tests\test_vehicle_module.py:11: Failed
============================= short test summary info ==========================
FAILED tests/test_vehicle_module.py::test_honk_horn_zero - Failed: DID NOT RAISE ValueError
========================== 1 failed, 3 passed in 0.13s =========================
This time, we get a failing test, because the honk_horn
function DID NOT raise a ValueError. Let’s change the
function in src/vehicle_module/horn_noises.py to the
following:
PYTHON
def honk_horn(times=1):
if times < 1:
raise ValueError("Times must be at least 1")
return "Honk! " * times
Running the tests again, we can see that all the tests pass.
Edge Cases / Test Driven Development
So we came up with a couple different ways that the
honk_horn function might fail, based on different inputs,
and we want to make sure our code handles these cases. We could edit the
code file first, then write the tests, but a more common practice is to
write the tests first, and then edit the code to make the tests pass.
This is called “Test Driven Development” (TDD) and has a couple of
benefits:
- It forces us to think about the different ways our code might fail,
and how we want to handle those cases ahead of time. (e.g. what should
happen if the user provides a value of 0 for the
timesparameter in thehonk_hornfunction?)
- It forces us to think about the different ways our code might fail,
and how we want to handle those cases ahead of time. (e.g. what should
happen if the user provides a value of 0 for the
- It gives us a safety net of tests that we can run after we make
changes to our code, to make sure we didn’t break anything. (e.g. if we
change the
honk_hornfunction to raise aValueErrorfor a value of 0, we can run our tests to make sure that the function still works as expected for other values.)
- It gives us a safety net of tests that we can run after we make
changes to our code, to make sure we didn’t break anything. (e.g. if we
change the
- It mimics the process for bug hunting - we can write a test that reproduces the bug, then edit the code to fix the bug, and then run the tests to make sure the bug is fixed and that we didn’t break anything else.
Let’s add an additional test for the honk_horn function
that check for one of the edge cases we discussed earlier:
PYTHON
def test_honk_horn_large_number():
with pytest.raises(ValueError):
vehicle_module.horn_noises.honk_horn(11)
Run the tests again:
And we should get a message that our test failed, because the
honk_horn function did not raise a ValueError
for a value of 11. Let’s update the honk_horn function to
the following:
PYTHON
def honk_horn(times=1):
if times < 1:
raise ValueError("Times must be at least 1")
if times > 10:
raise ValueError("Times must be at most 10")
return "Honk! " * times
What about some of the other edge cases we discussed earlier?
Well, let’s add them:
PYTHON
def test_honk_horn_non_integer():
with pytest.raises(TypeError):
vehicle_module.horn_noises.honk_horn("Two")
def test_honk_horn_negative():
with pytest.raises(ValueError):
vehicle_module.horn_noises.honk_horn(-1)
And run our tests again. They…pass?
When python tries to multiple a string by a float, a negative
integer, or another string, it will already raise a
TypeError or ValueError. While we could add
additional tests for these cases, it is not strictly necessary, as this
functionality is implemented in the Python language itself.
It’s very easy to find yourself drowning in tests, and writing tests for each and every possible edge case. This can even be counterproductive, as it can make it difficult to maintain your tests, and can slow down your overall development process.
Testing Classes
So that works well enough for the basic functions we wrote at the start, but what about testing the classes we made? The Vehicle, Car, GasolineCar and ElectricCar classes? We can test classes similarly to functions, by writing test functions that create instances of the class and check their behavior. Let’s start by looking at the test cases we wrote previously.
First off, we need to rename the file so that pytest can recognize it
as a test file. Let’s rename it to test_car.py.
We want our tests to ensure that we are able to create a Car object, and that the methods on the Car object operate as expected on an instance.
PYTHON
from vehicle_module.car import Car, ElectricCar, GasolineCar
# ... existing code
# Check that we can create a Car object
car = Car(make="Toyota", model="Corolla", year=2020)
if car.make == "Toyota" and car.model == "Corolla" and car.year == 2020:
passed_tests += 1
else:
failed_tests += 1
# Test the methods
if car.honk_horn() == "Honk! Honk!":
passed_tests += 1
else:
failed_tests += 1
if car.make_engine_noise() == "putt putt":
passed_tests += 1
else:
failed_tests += 1
In pytest form, this would look like the following:
PYTHON
def test_car():
my_car = Car(make="TestMake", model="TestModel", year=2026)
assert my_car.make == "TestMake"
assert my_car.model == "TestModel"
assert my_car.year == 2026
assert my_car.color == "grey"
assert my_car.fuel == "gasoline"
def test_car_noises():
my_car = Car(make="TestMake", model="TestModel", year=2026)
assert my_car.honk_horn() == "Honk! Honk!"
assert my_car.make_engine_noise() == "putt putt"
Note that we can use as many asserts as we like in each test function. If any one of the asserts fails, the test will fail.
Fixtures
One of the strengths of pytest is the ability to create fixtures,
which are reusable bits of code that can be used to quickly set up
objects or data for our tests. For example, notice that in each of our
tests so far, we have had to create a new instance of the
Car object with the same set of parameters. As programmers,
we are lazy! And this also could potentially create problems down the
line - what if we have a series of tests, all of which create a new
Car object, and then one day we decide to change the
parameters of the Car object? We would have to edit each
test individually, which is tedious and error-prone. Instead, we can
create a fixture that creates a Car object for us, and then
we can use that fixture in our tests.
Fixtures are special functions in pytest, but they are simple
functions in the end. There’s nothing stopping you from just creating a
function that returns a Car object, and then calling that
function in your tests. The difference is that fixtures are
automatically discovered by pytest, allowing us to create session-level
fixtures that need to be used everywhere, module level fixtures that are
used in multiple test files, etc. Fixtures can also be set to be
automatically applied, so we don’t have to explicitly include them in
our test functions.
Let’s create a fixture for our Car object. We’re using a
fixture that comes from pytest called
@pytest.fixture, so we’ll need to add an import to the top
of the file:
Then, add the following code below the imports:
Now, we can use this fixture in our tests. Update the test functions
to accept a parameter called my_car, and remove the line
that creates the Car object. The updated test file should
look like this:
PYTHON
import pytest
from vehicle_module.car import Car, ElectricCar, GasolineCar
@pytest.fixture
def my_car():
return Car(make="Toyota", model="Corolla", year=2020)
def test_car(my_car):
assert my_car.make == "Toyota"
assert my_car.model == "Corolla"
assert my_car.year == 2020
assert my_car.color == "grey"
assert my_car.fuel == "gasoline"
def test_car_noises(my_car):
assert my_car.honk_horn() == "Honk! Honk!"
assert my_car.make_engine_noise() == "putt putt"
Let’s run our tests again with uv run pytest. Hopefully,
all of our tests are still passing.
Monkey Patching
Another useful feature of pytest is monkey patching. Monkey patching is a way to modify or extend the behavior of a function or class during testing. This is useful when you want to test a function that depends on an external resource, such as a database, file system or web resource. Instead of actually accessing the external resource, you can use monkey patching to replace the function that accesses the resource with a mock function that returns a predefined value.
Remember in the last episode when we added our Car glyphs? Let’s write a simple check to make sure that we are getting the correct glyph.
Now in our test file, we can write a test that checks that the glyph
is the correct size. However, we don’t really want to have to ensure
that there is a file called car.glyph in the correct
location, instead we’ll use monkey patching to replace the
open function with a mock function that returns a string
that we define for the test. This way, we can test the behavior of the
glyph property and how it handles the contents of the
file.
Let’s add update our tests in test_car.py to include a
test for the glyph. First, we need to add an import for the
mock_open at the top of the file:
Wait, why are we using unittest.mock? I thought we were
using pytest? pytest is a great testing
framework, but it works well with the built-in
unittest.mock library, which is part of the Python standard
library. unittest.mock provides a way to create mock
objects and functions that can be used in tests.
pytest does have its own mocking library called
pytest-mock, but it is not part of the standard library,
and is not included in the default pytest. We aren’t going
to cover this here.
To use this in our test, we need to update our test to “monkeypatch”
the open function. When our test tries to run the
glyph property, it will try to open the file
car.glyph, but instead of actually opening the file, it
will use our mock function that returns a string that we define for the
test. Our test looks like this:
PYTHON
def test_car_glyph(my_car, monkeypatch):
file_content = """
1234567890
1234567890
1234567890
1234567890
"""
mock = mock_open(read_data=file_content)
monkeypatch.setattr("builtins.open", mock)
assert isinstance(my_car.glyph, str)
assert my_car.glyph == file_content.strip()
Strictly speaking, I think it would be better to test the glyph
property in a separate test file, like test_vehicle.py. We
can mention this.
Challenge: Fix a simple bug
We have a simple bug in our honk_horn function. When the
string is returned, it always has a space at the end, which is not what
we want. Update the test_honk_horn test to check that the
string returned has no space at the end, then update the
honk_horn function to fix the bug.
Does this change affect any other tests? If so, update those tests as well.
In test/test_vehicle_module.py, update the
test_honk_horn function to the following:
PYTHON
def test_honk_horn():
assert vehicle_module.horn_noises.honk_horn(2) == "Honk! Honk!" # Remove the space at the end
Then update the honk_horn function in
src/vehicle_module/horn_noises.py to the following:
PYTHON
def honk_horn(times=1):
if times < 1:
raise ValueError("Times must be at least 1")
if times > 10:
raise ValueError("Times must be at most 10")
return ("Honk! " * times).strip()
If you also have the test_play_engine_sound test, this
change will result in this test now failing. Update the
test_play_engine_sound function in
test/test_vehicle_module.py so that it now passes by
removing the space at the end of the expected string:
Challenge: Practice Writing Tests
If you didn’t have a chance to add the play_engine_sound
function from the earlier episode, let’s do that now. Create a file
called src/vehicle_module/engine_noise.py and add the
following code to it:
PYTHON
from vehicle_module.horn_noises import honk_horn
def play_engine_sound(rpm):
return honk_horn(1) + f"\nVroom! Engine at {rpm} RPM"
- Write a test for this function in
test/test_vehicle_module.pycalledtest_play_engine_sound. - Think of any common edge cases that might break this function, and write tests for those as well.
- Update the
play_engine_soundfunction to handle those edge cases, and make sure all of your tests still pass.
Some ideas for tests:
PYTHON
def test_play_engine_sound():
assert vehicle_module.engine_noise.play_engine_sound(3000) == "Honk!\nVroom! Engine at 3000 RPM"
def test_play_engine_sound_negative_rpm():
with pytest.raises(ValueError):
vehicle_module.engine_noise.play_engine_sound(-1000)
def test_play_engine_sound_non_integer_rpm():
with pytest.raises(TypeError):
vehicle_module.engine_noise.play_engine_sound("Three Thousand")
The updated play_engine_sound function could look like
this:
Challenge: Test Drive Development - Part 1
Another edge case for the play_engine_sound function
could be that the parameter rpm is 0. This is not a failure
case - the engine is just not running. We want to update the
play_engine_sound function to handle this case, returning
an entirely different string in the case that rpm is 0.
Start by writing a test for this case, then update the function to make the test pass.
Challenge: Test Drive Development - Part 2
We give our project to a co-worker and they let us know that the
glyph property will accept any size glyph, which he
suggests will break the display of the glyph in the terminal of our
final project.
We should add a test to our file that tries to monkeypatch in a glyph
that is not the correct size, and check that the glyph
property raises a ValueError when it is accessed. The glyph
must be exactly 4 lines of 10 characters each.
- Create a new test in the
test/test_car.pyfile calledtest_car_glyph_invalid_size. - Use the
monkeypatchfixture to replace theopenfunction with a mock function that returns a string that is not 4 lines of 10 characters each. - Ensure that this new test fails when you run
uv run pytest. - Update the
glyphproperty in theCarclass to raise aValueErrorif the glyph is not 4 lines of 10 characters each. - Run the tests again and ensure that all tests pass.
Use the test we made in the monkey patching section as a starting point.
Use the pytest.raises context manager to check that a
ValueError is raised when the glyph property
is accessed with an invalid glyph.
You can use the strip and splitlines
methods to get a list of lines from the glyph string.
The test in test/test_car.py should look like something
this:
PYTHON
def test_car_glyph_invalid_size(my_car, monkeypatch):
file_content = """
1234567890
123456789
1234567890
1234567890
"""
mock = mock_open(read_data=file_content)
monkeypatch.setattr("builtins.open", mock)
with pytest.raises(ValueError):
_ = my_car.glyph
And the function in src/vehicle_module/vehicle.py should
look like this:
PYTHON
# ... existing code
@property
def glyph(self):
resource = files(glyph).joinpath(self.glyph_file) # Get the path to the file in the package
with as_file(resource) as path: # Open the file
with open(path, "r", encoding="utf-8") as f: # Read the file
glyph_string = f.read() # And return the contents of the file
# Ensure that the glyph is 4 lines of 10 characters each
lines = glyph_string.strip().splitlines()
if len(lines) != 4 or any(len(line) != 10 for line in lines):
raise ValueError("Glyph must be exactly 4 lines of 10 characters each")
return glyph_string
- We can use
pytestto write and run unit tests in Python. - A good test is isolated, repeatable, fast, and clear.
- We can use fixtures to provide data or state to our tests.
- We can use monkey patching to modify the behavior of functions or classes during testing.
- Test Driven Development (TDD) is a practice where we write tests before writing the code to make the tests pass.