Writing Unit Tests for Python Applications with Pytest: A Basic to Practical Guide

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Context & Why Write Unit Tests for Python?

In the software development process, ensuring code quality is always a major challenge. Do you ever worry that changing a small line of code might break some feature in a distant part of the system? Or do you spend hours manually testing old functionalities every time you deploy a new feature?

These situations are very common for any developer. As projects grow and codebases expand, managing and maintaining quality becomes increasingly difficult. Bugs that arise often cost much more to fix if discovered late, especially after the product has reached users. A typical example is how a small error in the payment process could cost a company tens of thousands of dollars daily if not detected promptly.

The solution to this problem is Unit Test – unit testing. Unit tests help you independently check the smallest parts (units) of an application. Each unit, typically a function or method, is tested to ensure it operates as expected in all scenarios. This not only helps detect errors early but also provides a solid ‘safety net’ whenever you need to refactor code or add new features.

I once had the experience of refactoring a codebase of up to 50,000 lines of code. The biggest lesson I learned from that project was: you must have good test coverage before starting. Without unit tests, every code change is a huge gamble, carrying immeasurable risks.

This is especially true in large projects, where a small change can lead to unforeseen consequences. With unit tests, I feel much more confident when adjusting components because I immediately know if something is wrong. This helps reduce debugging time from hours to just minutes.

In Python, many frameworks support writing unit tests, but Pytest stands out due to its simplicity, readable syntax, and powerful extensibility.

Compared to the built-in unittest module, Pytest offers a more user-friendly testing experience, allowing you to focus on test logic rather than cumbersome boilerplate code. For beginners learning programming, mastering Pytest will be a worthwhile investment in high-quality software development skills, saving hundreds of hours of manual testing in the future.

Installing Pytest and Basic Project Structure

To get started with Pytest, you first need to ensure that Python and the pip package manager are installed on your computer. After that, we will install Pytest along with another useful library, pytest-cov (to measure test coverage).

1. Environment Setup (Optional but highly recommended)

I recommend using a virtual environment for each Python project. This method effectively helps avoid conflicts between library versions of different projects.


python3 -m venv venv
source venv/bin/activate

The above command will create a venv directory containing the virtual environment, then activate it. You will see (venv) appear at the beginning of your command line, indicating that the virtual environment is ready.

2. Install Pytest and pytest-cov


pip install pytest pytest-cov

After running this command, Pytest and pytest-cov are ready for immediate use.

3. Project Structure

A simple but effective project structure for testing would look like this:


my_project/
├── src/
│   └── my_module.py
└── tests/
    └── test_my_module.py
  • src/: Contains the main source code of the application.
  • tests/: Where all test files are stored. Pytest will automatically search for files with the prefix test_ or suffix _test.py in this directory.

Detailed Configuration and Writing Effective Unit Tests with Pytest

With Pytest, writing test cases becomes very intuitive and easy to understand. We will delve into the main components so you can create high-quality tests.

1. Writing Your First Test Case

Let’s start with a simple Python function in src/my_module.py:


# src/my_module.py

def add(a, b):
    """Adds two numbers a and b.

    Args:
        a (int/float): The first number.
        b (int/float): The second number.

    Returns:
        int/float: The sum of a and b.
    """
    return a + b

def subtract(a, b):
    """Subtracts two numbers a and b.

    Args:
        a (int/float): The number to be subtracted from.
        b (int/float): The number to subtract.

    Returns:
        int/float: The difference of a and b.
    """
    return a - b

Now, create a corresponding test file in tests/test_my_module.py:


# tests/test_my_module.py

from src.my_module import add, subtract

def test_add_positive_numbers():
    assert add(1, 2) == 3

def test_add_negative_numbers():
    assert add(-1, -2) == -3

def test_subtract_numbers():
    assert subtract(5, 3) == 2

def test_subtract_negative_result():
    assert subtract(3, 5) == -2

Detailed Explanation:

  • Pytest automatically finds functions starting with test_ in files prefixed with test_.
  • The assert statement is used to check a specific condition. If the condition is False, the test will fail, indicating an error.

2. Using Fixtures (Setup Aids)

Fixtures are functions that Pytest runs before (and sometimes after) executing one or more test cases. They are very useful for setting up the test environment (e.g., creating objects, connecting to a database, creating temporary files) and cleaning up afterwards. This makes your test code cleaner, more reusable, and more reliable.

For example, suppose you have a test that needs to create a temporary file. Instead of manually creating and cleaning it up, you can use a fixture:


# tests/test_my_module.py (continued)

import pytest
import os

@pytest.fixture
def temp_file(tmp_path):
    # tmp_path is a built-in pytest fixture that helps create temporary directories
    file_path = tmp_path / "test.txt"
    file_path.write_text("Hello Pytest!")
    yield file_path # Code after 'yield' will run after the test completes (for cleanup)
    # In reality, tmp_path cleans itself up, but this is an example illustrating how yield works
    # print(f"Cleaning up temporary file: {file_path}") # Just for illustration

def test_read_temp_file(temp_file):
    # temp_file here is the value returned from the temp_file fixture
    with open(temp_file, 'r') as f:
        content = f.read()
    assert content == "Hello Pytest!"
    assert os.path.exists(temp_file) # Check if the file actually exists

In this example, temp_file is a fixture that will create a temporary file and provide its path to the test_read_temp_file test case. Pytest will automatically manage the lifecycle of this fixture, ensuring the test environment is always clean.

3. Parameterization

When you need to test a function with various sets of input data but the same testing logic, parameterization is a powerful tool. Instead of writing multiple duplicate test cases, you can define one test case and provide a list of parameters. This significantly reduces the amount of test code.


# tests/test_my_module.py (continued)

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, -2, -3),
    (0, 0, 0),
    (100, -50, 50),
])
def test_add_various_inputs(a, b, expected):
    assert add(a, b) == expected

@pytest.mark.parametrize("a, b, expected", [
    (5, 3, 2),
    (3, 5, -2),
    (0, 0, 0),
    (10, -5, 15),
])
def test_subtract_various_inputs(a, b, expected):
    assert subtract(a, b) == expected

The @pytest.mark.parametrize decorator takes two arguments: a string containing comma-separated parameter names, and a list of tuples containing corresponding values for each test run. The example above will create 4 separate test cases for the add function and 4 test cases for the subtract function from a single definition.

4. Mocking

When your functions interact with external systems (APIs, databases, file systems), testing can become complex and slow. Mocking allows you to replace these external components with simulated objects (mocks) that have predefined behavior. This helps you test the function’s logic independently and much more quickly.

Pytest works very well with the unittest.mock library. For example, if you have a function that calls an API:


# src/my_module.py
import requests

def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status() # Raise an exception for HTTP errors
    return response.json()

You can mock requests.get as follows:


# tests/test_my_module.py (continued)

from unittest.mock import patch

def test_get_user_data_success():
    with patch('src.my_module.requests.get') as mock_get:
        # Configure mock object
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"id": 1, "name": "Test User"}
        mock_get.return_value.raise_for_status.return_value = None

        data = get_user_data(1)
        assert data == {"id": 1, "name": "Test User"}
        mock_get.assert_called_once_with("https://api.example.com/users/1")

def test_get_user_data_http_error():
    with patch('src.my_module.requests.get') as mock_get:
        mock_get.return_value.status_code = 404
        mock_get.return_value.json.return_value = {}
        # Mock raise_for_status to raise an HTTPError exception
        mock_get.return_value.raise_for_status.side_effect = requests.exceptions.HTTPError

        with pytest.raises(requests.exceptions.HTTPError):
            get_user_data(2)

Using patch, we have replaced the requests.get function with a mock object. This allows us to control the return value and check if the function was called correctly without actually calling an external API.

Testing & Monitoring: Ensuring Continuous Quality

After writing the test cases, the next step is to run them and analyze the results. Pytest provides a powerful set of tools to do this, helping you easily monitor code quality.

1. Running Unit Tests

From the project’s root directory (my_project/), you just need to run the pytest command:


(venv) $ pytest

Pytest will automatically search for and run all test cases it finds in the project. The output will tell you how many tests were run, how many passed (indicated by .), failed (indicated by F), or had errors (indicated by E).

2. Useful Command Line Options

  • pytest -v: Verbose mode, showing more details for each running test case.
  • pytest -s: Allows displaying print() statements during testing. Very useful for debugging.
  • pytest -k "add and not negative": Only runs test cases whose names contain “add” and do not contain “negative”.
  • pytest tests/test_my_module.py::test_add_positive_numbers: Runs a specific test case.
  • pytest --maxfail=1: Stops test execution immediately after the first failed test case. Useful when you want to fix errors quickly.

(venv) $ pytest -v
============================= test session starts ==============================
platform linux -- Python 3.x.x, pytest-x.x.x, pluggy-x.x.x -- /home/user/my_project/venv/bin/python
rootdir: /home/user/my_project
plugins: cov-x.x.x
collected 8 items

tests/test_my_module.py::test_add_positive_numbers PASSED                 [ 12%]
tests/test_my_module.py::test_add_negative_numbers PASSED                 [ 25%]
tests/test_my_module.py::test_subtract_numbers PASSED                     [ 37%]
tests/test_my_module.py::test_subtract_negative_result PASSED             [ 50%]
tests/test_my_module.py::test_read_temp_file PASSED                      [ 62%]
tests/test_my_module.py::test_add_various_inputs[1-2-3] PASSED            [ 75%]
tests/test_my_module.py::test_add_various_inputs[-1--2--3] PASSED         [ 87%]
tests/test_my_module.py::test_add_various_inputs[0-0-0] PASSED            [100%]

============================== 8 passed in X.XXs ===============================

3. Measuring Test Coverage

Test coverage is an important metric that indicates what percentage of your source code has been tested by unit tests. High coverage doesn’t guarantee bug-free code, but it shows that you’ve tested most branches and lines of code. The pytest-cov tool we installed will help measure this metric easily.

To run tests and generate a coverage report, use the command:


(venv) $ pytest --cov=src --cov-report=term-missing
  • --cov=src: Specifies the directory or module you want to measure coverage for (here, the src directory).
  • --cov-report=term-missing: Displays a detailed report directly on the terminal, including lines of code that have not been tested.

============================= test session starts ==============================
...
-------------------------- coverage: platform linux --------------------------
Name                  Stmts   Miss  Cover   Missing
---------------------------------------------------
src/my_module.py         12      0   100%   
---------------------------------------------------
TOTAL                    12      0   100%
============================== 8 passed in X.XXs ===============================

This report shows that our src/my_module.py achieved 100% coverage, meaning every line of code within it was executed at least once by the test cases. If there are untested lines of code, you will see them clearly listed in the ‘Missing’ column, helping you easily add tests.

4. Integration with CI/CD (Continuous Integration/Continuous Deployment)

Unit tests are an indispensable part of modern CI/CD pipelines. By automatically running all tests whenever changes are pushed to the repository, you can ensure that new code does not introduce regressions and maintain the overall quality of the application.

While setting up CI/CD is a broader and more complex topic, essentially, CI/CD systems will call the pytest command similarly to how you run it locally to check the source code before allowing it to be merged or deployed. This helps catch errors early, saving time and resources.

Conclusion

Writing unit tests with Pytest is not just a necessary skill but also an important mindset in professional software development. It helps you build more sustainable applications, minimize errors, speed up refactoring, and instill confidence in the entire development team.

With this basic to practical knowledge, I believe you have enough tools to effectively start your journey of testing Python applications. Don’t hesitate to apply Pytest to your next project to see a clear difference in code quality.

Share: