Setting Up a PyTest Project

To begin, install PyTest using pip:

pip install pytest

Create a test file, typically named test_*.py, and place it in a tests/ directory adjacent to your source code. For example, if you have a module calculator.py, create a file test_calculator.py.

Writing Basic Tests with PyTest

PyTest test functions are simple Python functions prefixed with test_. Here’s an example:

# test_calculator.py
def add(a, b):
    return a + b

def test_add_integers():
    assert add(2, 3) == 5

def test_add_strings():
    assert add("Hello, ", "world!") == "Hello, world!"

Run the tests with:

pytest test_calculator.py

This shows the core of PyTest’s design: minimal boilerplate and readable test functions.

Using Fixtures for Test Setup and Teardown

Fixtures in PyTest are functions that provide a fixed baseline for tests. They are especially useful for setting up test data, initializing objects, or managing database connections. Fixtures are declared using the @pytest.fixture decorator.

Example: Using a Simple Fixture

import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

Here, sample_data is a reusable fixture that returns a list of integers used in multiple tests.

Scope and Lifetime of Fixtures

Fixtures can be scoped to function, class, module, or session levels. The default scope is function, but you can change it using the scope parameter:

@pytest.fixture(scope="module")
def expensive_resource():
    print("Setting up expensive resource")
    return "resource"

@pytest.fixture
def use_resource(expensive_resource):
    return f"Using {expensive_resource}"

In this case, expensive_resource is initialized once per module, reducing setup time.

Parametrizing Tests

PyTest supports test parametrization, allowing the same test function to run with multiple sets of input arguments. This is particularly useful for testing edge cases or multiple scenarios.

Example: Parametrized Tests

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (1.5, 2.5, 4.0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

The @pytest.mark.parametrize decorator runs the test_add function with all provided input combinations.

Mocking with Fixtures

PyTest integrates well with the unittest.mock module to simulate external dependencies. This is essential for testing functions that interact with databases, APIs, or system calls without relying on them.

Example: Mocking a File Read

from unittest.mock import mock_open, patch
import pytest

def read_file(path):
    with open(path, 'r') as f:
        return f.read()

@patch("builtins.open", new_callable=mock_open, read_data="mocked content")
def test_read_file(mock_file):
    content = read_file("dummy.txt")
    assert content == "mocked content"

Fixtures for Dependency Injection

Fixtures can be used as arguments in test functions to inject dependencies. This is powerful for dependency injection and test isolation.

Example: Using Multiple Fixtures

@pytest.fixture
def user_data():
    return {"name": "Alice", "age": 30}

@pytest.fixture
def formatted_user(user_data):
    return f"{user_data['name']} ({user_data['age']})"

def test_formatted_user(formatted_user):
    assert formatted_user == "Alice (30)"

Best Practices

PracticeDescription
Use descriptive fixture namesAvoid generic names like data or setup. Use user_profile or config_data instead.
Favor scoped fixturesUse module or session scope when setup is expensive.
Parametrize edge casesDon’t forget to test with 0, None, or negative values.
Avoid global statePrefer fixtures over global variables for test isolation.
Keep tests smallEach test should focus on a single behavior.

Learn more with useful resources