Mar 25, 20264 min

Pytest in Practice 2026: A Python Developer Testing Guide

A practical 2026 Pytest guide for Python developers covering first tests, parametrization, marks, fixtures, pytest.raises, mocks, monkeypatching, and teardown patterns.

Testing is a core part of modern software development in 2026. Some developers write tests after implementation. Others prefer TDD, where the test comes first and the production code follows.

Either way, a healthy test suite helps catch bugs early, protects refactoring, and keeps projects from becoming fragile as they grow.

For Python, one of the best everyday testing tools is pytest: powerful, flexible, and pleasant enough for both small scripts and large applications.

This guide covers the essentials: first tests, parametrization, marks, fixtures, exception assertions, mocks, monkeypatching, and teardown patterns.

Why Automated Tests Matter

Automated tests do not guarantee that your software has no defects. They do reduce the chance of shipping obvious regressions and serious logic bugs.

Good tests also work as living documentation. They show how the code is supposed to behave in real scenarios.

Most importantly, tests make refactoring less scary. When the suite is reliable, you can change internals while keeping behavior intact.

Your First Pytest Test

Install Pytest:

pip install pytest

Create test_example.py:

def test_answer() -> None:
    assert 2 + 2 == 4

Run it:

pytest .

You should see one passing test:

============ test session starts ============
collected 1 item

test_example.py .                            [100%]

============ 1 passed in 0.00s =============

If you change the assertion to assert 2 + 2 == 5, Pytest prints a useful failure message:

E       assert 4 == 5

That clear assertion output is one reason Pytest feels so productive.

A More Useful Example: Factorial

Here is a small factorial function:

def factorial(n: int) -> int:
    if n in [0, 1]:
        return 1
    return n * factorial(n - 1)

A single test works:

def test_factorial() -> None:
    expected = 120
    got = factorial(5)
    assert expected == got

But 0, 1, and 5 are different cases. Writing separate tests is fine:

def test_factorial_return_one_if_zero() -> None:
    assert factorial(0) == 1


def test_factorial_return_one_if_one() -> None:
    assert factorial(1) == 1


def test_factorial_of_five() -> None:
    assert factorial(5) == 120

The tests are almost identical, so parametrization is cleaner.

import pytest


@pytest.mark.parametrize(
    ("number", "expected"),
    [
        (0, 1),
        (1, 1),
        (5, 120),
    ],
)
def test_factorial(number: int, expected: int) -> None:
    assert factorial(number) == expected

This single test function runs once for each input pair.

Useful Pytest Marks

@pytest.mark.parametrize groups multiple scenarios into one test. You can stack several parametrization decorators, but be careful: combinations grow quickly.

@pytest.mark.skip skips a test completely:

@pytest.mark.skip(reason="Awaiting fix in the next library release")
def test_some_function() -> None:
    ...

@pytest.mark.skipif skips a test only when a condition is true:

import sys
import pytest


@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_unix_behavior() -> None:
    ...

@pytest.mark.xfail marks a known failure without breaking the entire build:

@pytest.mark.xfail(reason="Waiting for new data updates on the server")
def test_data_sync() -> None:
    ...

Use marks to make test intent explicit instead of hiding fragile behavior.

Fixtures for Setup and Teardown

Suppose a class retrieves prices from different data sources:

class PriceManager:
    def __init__(self, x_price_source, y_price_source):
        self.x_price_source = x_price_source
        self.y_price_source = y_price_source

    def get_price(self, product):
        if product.type == "x":
            return self.x_price_source.get(product)
        if product.type == "y":
            return self.y_price_source.get(product)
        return None

Creating PriceManager inside every test quickly becomes repetitive. A fixture keeps setup in one place:

from decimal import Decimal
import pytest


@pytest.fixture()
def price_manager():
    return PriceManager(
        x_price_source=StubXPriceSource(Decimal("150.00")),
        y_price_source=StubYPriceSource(Decimal("220.00")),
    )


def test_get_price_x(price_manager):
    product = Product(type="x")
    assert price_manager.get_price(product) == Decimal("150.00")

Fixtures reduce duplication, make setup reusable, and can depend on other fixtures. They can also run at different scopes: function, class, module, package, or session.

Parametrizing Fixtures

Fixtures can be parametrized too:

import pytest


@pytest.fixture(params=[
    {"name": "Rashid", "age": 27},
    {"name": "Mamed", "age": 31},
])
def user(request):
    user_data = request.param
    user = UserModel(name=user_data["name"], age=user_data["age"])
    user.save()
    yield user
    user.delete()


def test_user_presentation(user):
    print(f"{user.name}, {user.age}")

Pytest will run the test twice, once for each fixture value.

Checking Exceptions with pytest.raises

Use pytest.raises when the behavior you want is an exception:

import pytest


def test_get_raises_error_when_not_found(my_repo):
    with pytest.raises(EntityDoesNotExistError) as exc_info:
        my_repo.get(999)

    assert "999" in str(exc_info.value)

This makes failure behavior explicit and testable.

Mocking External Dependencies

Mocks let you isolate the code under test from remote services, databases, or slow APIs.

def test_service_auth(mocker):
    transport_mock = mocker.Mock()
    transport_mock.get.side_effect = [
        HTTPAuthError("Unauthorized"),
        None,
        [{"name": "Alice"}, {"name": "Bob"}],
    ]

    client = MyHttpClient(transport=transport_mock)
    result = client.get_all_users()

    assert len(result) == 2

If dependency injection is not available, monkeypatching can help:

def test_read_file(mocker):
    file_mock = mocker.mock_open(read_data="Hello, World!")
    mocker.patch("my_module.open", file_mock)

    got = FileReader.read("dummy.txt")
    assert got == "Hello, World!"

Use patching carefully. It is powerful, but tests become harder to understand when too many internals are replaced.

Yield Fixtures for Cleanup

Fixtures can clean up resources after the test:

@pytest.fixture()
def open_file():
    file_obj = open("test.txt", "w")
    yield file_obj
    file_obj.close()

For more complex teardown logic, use request.addfinalizer:

@pytest.fixture()
def create_user(request):
    user = User(name="TempUser")
    user.save()

    def remove_user():
        user.delete()

    request.addfinalizer(remove_user)
    return user

yield fixtures are usually easier to read, but finalizers are useful when cleanup needs to be registered dynamically.

Key Takeaways

Write tests frequently so bugs do not pile up.

Use fixtures to remove repeated setup and make tests easier to scan.

Use marks to skip platform-specific tests, document known failures, and parametrize coverage.

Mock external dependencies when real services would make tests slow, flaky, or expensive.

Pytest remains one of the most flexible testing tools in the Python ecosystem. In 2026, it is still a great default for teams that want clear, maintainable, confidence-building tests.