Unit Testing in Python with pytest
Unit Testing in Python with pytest
In this article, we will cover the basics of unit testing in Python, covering what exactly unit testing is, why you should be writing unit tests for all of your code, and lastly, covering the basics of writing simple unit tests with the pytest
library.
What Are Unit Tests?
Unit tests cover the smallest testable portions of your code, such as individual functions and methods. These tests are meant to verify the functionality of an isolated portion of your code, allowing you to ensure that a particular component of code does exactly what it is meant to, and nothing else. In a nutshell, a unit test is a separate piece of code that calls the function or method it is testing and verifies the result. When you have written a number of unit tests, these are often referred to as a "test suite".
When you're writing and running unit tests, you have assurances that the code covered by the unit tests is functioning as expected and you get fast feedback when anything within your codebase breaks, helping you avoid unexpected bugs. It is important to note that unit tests should not have external dependencies on other parts of your application or other services. If you have a test that relies on a class and two methods, it may not be immediately obvious which portion has misbehaved. If your unit test contains just a single method, you will know exactly where the issue lies.
Why Is Unit Testing Important?
Unit testing in Python forms a critical component of the application development lifecycle and should be considered required for anything beyond simple scripts. These tests act as a safety net for your code, helping you (or other developers, if you're working on a shared project) ensure that you can catch bugs early on in the development lifecycle, avoiding issues in production. This gives you confidence when refactoring your code, because you know that you still have a suite of unit tests that validate that previously working functionality continues to behave as expected.
Code Quality
Unit testing helps maintain code quality and improves the reliability of the code you are writing. Creating comprehensive unit tests ensures that each function and method does (and continues to do) what it is expected to do. Taking the time to validate and test your code is the mark of a competently executed development project and should be something that you consider a requirement for any project you work on.
Prevention of Regression
Unit tests help ensure that when you make changes to the codebase or add new features, you don’t accidentally break existing functionality. This is known as "regression testing". Regression testing is hugely important when working iteratively on applications, if you add a new feature, you need to be able to ensure that your previously verified functionality has not broken. As an application grows, it becomes more difficult to remember all of the interdependencies and regression testing (enabled with unit tests) ensures that nothing is broken as new capabilities get added.
Structure of a Unit Test
In general, a unit test follows a simple structure: set up the inputs (Arrange), run the function (Act), and check the result (Assert). This pattern, called "Arrange, Act, Assert", helps you logically organize a unit test and improves the clarity for anyone who will have to come after you to maintain the test. The most obvious section will be the "Assert" section, as it typically consists of one or more assert
statements that validate your assumptions about the outputs of your code.
How Do You Unit Test Code?
To think about the process of unit testing code, you will want to spend some time thinking about the different pathways that your code might go. The most obvious is the expected path (or "happy path"), where all of the inputs are exactly as expected and there are no issues with configuration, file read/write, etc. However, this is not always the case in the real world and designing a robust application means anticipating issues before they arise and cause errors in your application. For example, your function might expect an input from the user that will have some operations performed on it. But what happens if instead of an expected integer, the user passes a string or a list? Other areas that require special consideration in your testing are boundary conditions. These are the areas where your function might flip from one value to another. A simple example might be a function that depends on receiving a positive integer input. A good unit test in this instance could be to test inputs of 1, 0, and -1 to ensure that the function handles them appropriately or displays the correct error message. Your code needs to be able to gracefully handle any number of inputs and unit testing is the first line of defense.
Once you’ve identified the different expected cases, error cases, and boundary cases your function should handle, you’re ready to start writing tests with a testing framework like pytest
. The pytest
library is one of the most widely used unit testing libraries in the Python ecosystem and supports use cases ranging from simple to extremely complex. To get started with pytest, you'll just need to install it with pip. Note that here, we are using pytest version 8.4.0.
% python -m pip install --upgrade pytest==8.4.0
Once you've installed pytest, you can verify your installation by running the following
% python -m pytest --version
> pytest 8.4.0
Now that we've got pytest installed on our machine, let's create a sample function that we can write a test for. We'll keep it simple and write a function that just adds one to the given number and returns it. Create a file in your current working directory called test_sample_function.py
# within test_sample_function.py
def sample_function(number):
"""This sample function adds one to the provided number."""
return number + 1
Now that you have your function, we can add our first, basic unit test. In the same file, we'll define another function named test_sample_function_pass
and give it the following definition.
# within test_sample_function.py
def test_sample_function_pass():
"""Passing unit test for our sample_function."""
assert sample_function(2) == 3
Now that we have a defined function and a unit test, let's run the test to see what the output looks like. From the same directory as your test_sample_function.py
file, open up a terminal and run python -m pytest
% python -m pytest
============================ test session starts =============================
platform darwin -- Python 3.13.2, pytest-8.4.0, pluggy-1.5.0
rootdir: /Users/tim/Projects/writing
plugins: anyio-4.9.0, cov-6.1.1
collected 1 item
test_sample_function.py . [100%]
=========================== **1 passed** in 0.00s ============================
If you don't see something like what is shown above, check that your terminal is pointed to the same directory as where your python test file is located and double check that your file starts with test_
.
Now that we've created a passing unit test, let's see what happens when a unit test fails and how we can get it passing again.
In the same test_sample_function.py
file, create a new unit test called test_sample_function_fail()
with the following definition.
def test_sample_function_fail():
"""Failing unit test for our sample_function."""
assert sample_function("a") == 3
Let's run pytest again and see what the output looks like when a test doesn't pass.
% python -m pytest .
============================ test session starts =============================
platform darwin -- Python 3.13.2, pytest-8.4.0, pluggy-1.5.0
rootdir: /Users/tim/Projects/writing
plugins: anyio-4.9.0, cov-6.1.1
collected 2 items
test_sample_function.py .F [100%]
================================== FAILURES ==================================
_________________________ test_sample_function_fail __________________________
def test_sample_function_fail():
"""Failing unit test for our sample_function."""
> assert sample_function("a") == 3
^^^^^^^^^^^^^^^^^^^^
test_sample_function.py:12:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
number = 'a'
def sample_function(number):
"""This sample function adds one to the provided number."""
> return number + 1
^^^^^^^^^^
E TypeError: can only concatenate str (not "int") to str
test_sample_function.py:4: TypeError
========================== short test summary info ===========================
FAILED test_sample_function.py::**test_sample_function_fail** - TypeError: can only concatenate str (not "int") to str
======================== 1 failed, 1 passed in 0.02s =========================
As expected, our unit test did not pass. But, pytest includes a lot of helpful information that can help us diagnose what the cause of the particular error is. We see for the unit test itself, pytest will show us what the actual error was, TypeError
in this case because we are trying to add 1
to "a"
. This helps us fix our unit tests, either by correcting our functions, assertions, or modifying our inputs to address the failing test. To fix this, let's use some more of pytest's functionality to modify our unit test and run the code again to see the test passing. Let's go back to our test_sample_function.py
file and make the following modifications.
import pytest
... # Your other code stays the same
def test_sample_function_fail():
"""Failing unit test for our sample_function."""
with pytest.raises(TypeError):
sample_function("a") == 3
Here, we're using pytest.raises
as a context manager to verify that an expected exception is raised (TypeError
in this instance).
After modifying the file and saving it, run pytest again to verify that the unit test now passes.
% python -m pytest .
============================ test session starts =============================
platform darwin -- Python 3.13.2, pytest-8.4.0, pluggy-1.5.0
rootdir: /Users/tim/Projects/writing
plugins: anyio-4.9.0, cov-6.1.1
collected 2 items
test_sample_function.py .. [100%]
============================= 2 passed in 0.01s ==============================
We now have two unit tests for our function, one that tests whether the function performs its expected behavior and one that tests whether the function raises a particular error when given an incorrect input. While these are contrived examples, hopefully you can see that writing unit tests does not have to be overly complicated and can make a big difference in your codebase.
Best Practices for Getting Started
To begin with unit testing in Python with pytest, you can start with the following practices.
- Start small by writing a test for every new function: For each new function or method that you add to your application, make sure that it has an accompanying unit test. You do not need to try to boil the ocean and write a test for every single function you've already written, it is ok to move forward one function at a time!
- Keep tests fast and deterministic: Each function or method under test should always return a deterministic result (remember the definition of a function from algebra?). You want to make sure that you can reliably identify a specific output from a given input and if your tests are inconsistent in whether or not they pass, this will erode trust in the entire testing process.
- Make sure to use meaningful assertion messages in your unit tests: These messages, which will be output if any assertion fails, will help you remember what your expectation of the unit test was. This is especially useful when your unit tests progress beyond simple examples with basic data types. The following assertion is a simple example.
assert sample_function(2) == 3, "Expected 3 when input is 2"
- Use descriptive test function names: Like all naming within your application, you should use descriptive names for test functions that describe what the test is going to do. In the examples earlier, I used
test_sample_function_pass
andtest_sample_function_fail
as the test function names, which, while not the most descriptive ever, let the reviewer know what the expected behavior of the function under test was. - Don’t aim for 100% coverage immediately, focus on the critical paths of your application: This can help you avoid some of the overwhelm that might come if you approach an existing project with the mindset of "I want to test everything". Break down your application into logical components and begin by writing unit tests for the most crucial pieces of the software. As you add new functionality, make sure you're adding tests (see above), which will help you increase your overall coverage.
Common Mistakes
When writing unit tests for the first time (or even after a number of times), there are a few common mistakes that can trip up developers. I'll touch on a few of these mistakes here, but these are by no means exhaustive.
Test Discovery Issues
By default, pytest has specific test discovery rules that it follows to find the tests that will be executed. In a basic example, with no other options specified, pytest will recursively search directories (starting from the current working directory) and look for any files starting with test_*.py
or *_test.py
. Then, within those files, it will collect any test items, which are functions or methods prefixed with test
. There are more nuances, but if you are trying to run pytest and no tests are being executed, it could be worthwhile to verify your tests are being discovered.
Depending on External States
One very common mistake that beginners will make when creating unit tests is to create unit tests that depend on external states, whether it is a file, database, or an internet service. If your test depends on a file to execute successfully, what happens if that file no longer exists? Or if the file has been modified in some unexpected way? A principle of unit tests is to keep them isolated to just the function under test to avoid these types of issues. The Python ecosystem has tools to help manage external state dependencies (mocks, etc.), which I will cover in a future article, but for now, if you find yourself needing lots of setup code, consider whether the function should be split into smaller, more testable pieces.
Overly Complicated Tests
As I wrote at the outset of the article, unit tests are the smallest testable portions of your code. If you find yourself having to call multiple functions or methods to get another function set up, it is a good sign that your unit test is starting to get overly complicated and it may even be worthwhile revisiting your code structure to see if it can be simplified.
Where to Go Next
While I have covered some of the basics of testing with pytest in this article, there are large numbers of additional features that can be used to improve the reliability and robustness of your unit testing. More advanced concepts like mocking, parameterization (with @pytest.mark.parametrize
), and code coverage are all topics worthy of more in-depth study for a Python developer looking to expand their knowledge of unit testing. Another natural step is integrating your tests into a continuous integration (CI) pipeline like GitHub Actions or pre-commit hooks, so they run automatically whenever you or your team make changes. Adopting these practices will not only expand your testing skillset, but they will make your codebase more reliable and maintainable as it grows.
Sources
- Unit testing
- Regression testing
- pytest
- pytest test discovery
- I would recommend Brian Okken's book "Python Testing with pytest" for someone who is interested in getting a much more in-depth knowledge of pytest