Writing Tests with pytest in Python

This pytest tutorial python closes out the testing mini-series in this course. Where the earlier unittest deep-dive covered the standard library's own testing tools in depth, this article goes equally deep into pytest fixtures, parametrization, and mocking — the features that go well beyond the basic-assert level already covered in the introduction article, and genuinely explain why pytest has become the ecosystem standard.

Why pytest Is the Ecosystem Standard

A quick recap

As covered in the earlier articles: pytest needs no class inheritance and no special assertion method names — plain assert statements work directly, and pytest rewrites them internally to produce detailed, informative failure output automatically, without you needing unittest's assertEqual/assertTrue/etc. family of methods at all.

Three headline advantages, named upfront

Simplified syntax — plain functions, plain assert, no boilerplate class inheritance required. Automatic test discovery — any file matching test_*.py, containing any function matching test_*, is found and run automatically, with zero manual registration. A rich plugin ecosystem — coverage reporting, mocking helpers, timeout enforcement, and framework-specific integrations, all building on pytest's core in ways unittest alone doesn't offer out of the box.

What this article covers

Writing and running basic tests, fixtures (including scope, one of pytest's most genuinely powerful features), parametrization, exception testing, and mocking dependencies with pytest-mock — the areas that represent pytest's real, substantial edge over the basics already covered.

Writing and Running Basic Tests

Installation and project layout
pip install pytest
myproject/
├── src/
│   └── calculator.py
└── tests/
    └── test_calculator.py
A basic test example
# src/calculator.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
# tests/test_calculator.py
from src.calculator import add, divide

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

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

No class, no imports beyond the code actually under test, no special assertion methods — just plain functions named test_*, using plain assert.

Running tests
pytest                      # run the entire test suite
pytest tests/test_calculator.py   # run just one file
pytest -v                    # verbose output — shows each test's name and result individually
pytest -x                    # stop at the first failure, rather than running everything

-v is genuinely useful during active development, when you want to see exactly which specific tests passed or failed, rather than just a summary count. -x is useful when you're actively fixing something and want to stop immediately at the first failure, rather than waiting for the entire suite (which might contain many other, currently-irrelevant failures) to finish running.

Fixtures: Reusable Setup with @pytest.fixture

The problem fixtures solve

Real tests frequently need shared setup — a fresh object, a mock database connection, an API client — and repeating that setup code identically across dozens of test functions is genuinely wasteful and hard to keep consistent as it evolves.

The basic pattern
import pytest

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

@pytest.fixture
def cart():
    return ShoppingCart()   # a fresh cart, provided automatically to any test that asks for it

def test_add_item(cart):   # 'cart' is automatically injected by pytest
    cart.add_item("apple")
    assert cart.items == ["apple"]

def test_empty_cart_starts_empty(cart):
    assert cart.items == []

Any test function that includes a parameter matching a fixture's name — cart, here — automatically receives that fixture's return value, injected directly by pytest. Each test gets its own fresh ShoppingCart instance by default, ensuring test isolation, exactly the same principle setUp() provides in unittest, but expressed more flexibly.

Sharing fixtures project-wide with conftest.py

Placing fixtures inside a special file named conftest.py makes them automatically available to every test file in that directory (and its subdirectories), without any explicit import required:

# tests/conftest.py
import pytest
from src.shopping_cart import ShoppingCart

@pytest.fixture
def cart():
    return ShoppingCart()
# tests/test_checkout.py — 'cart' is available here automatically, no import needed
def test_checkout_calculates_total(cart):
    cart.add_item("apple")
    ...

This is genuinely valuable for larger projects — common setup logic lives in exactly one place, rather than being duplicated (and potentially drifting out of sync) across every individual test file that happens to need it.

Fixture scope

By default, a fixture is recreated fresh for every single test function that uses it (scope="function", the implicit default). For genuinely expensive setup — establishing a real database connection, starting a test server — recreating it for every single test can be wastefully slow. scope lets you control this:

@pytest.fixture(scope="function")   # default — fresh for every test
def cart():
    return ShoppingCart()

@pytest.fixture(scope="module")   # created once, reused for every test in this file
def database_connection():
    c create_expensive_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="session")   # created once, reused across the ENTIRE test run
def app_config():
    return load_config()

The four scope levels: function (default — fresh per test), class (shared across all tests in one class), module (shared across all tests in one file), and session (shared across the entire test run, regardless of how many files or tests exist). Choosing the right scope is a genuine trade-off: broader scope means faster test runs (expensive setup happens less often), but also means tests sharing that fixture are less fully isolated from each other — a mistake in one test that mutates shared state could, in principle, affect a later test sharing the same fixture instance. Use the narrowest scope that's still reasonably fast; reach for a broader scope specifically once you've confirmed the setup cost is genuinely significant.

Parametrization and Exception Testing

@pytest.mark.parametrize
import pytest
from src.calculator import add

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

This single test function runs four separate times, once per tuple, with pytest reporting each combination's pass/fail individually. As covered in the earlier unittest deep-dive, this is a genuine, clear ergonomic win over unittest's clunkier alternatives — a manual loop (which loses per-case failure granularity) or subTest() (which works, but is noticeably more verbose to set up for the same result).

Using ids= for readable test names
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, -1, -2),
    (0, 0, 0),
], ids=["positive_numbers", "negative_numbers", "zeros"])
def test_add(a, b, expected):
    assert add(a, b) == expected

Without ids=, pytest's verbose output labels each parametrized case with an anonymous index (test_add[a0], test_add[a1], and so on) — functional, but not especially informative at a glance. ids= gives each case a genuinely readable name (test_add[positive_numbers]), making test output — and especially a failure report — considerably easier to interpret immediately, without needing to cross-reference back to the parametrize list to figure out which specific case actually failed.

pytest.raises() for exception testing
import pytest
from src.calculator import divide

def test_divide_by_zero_raises():
    with pytest.raises(ValueError):
        divide(10, 0)

def test_divide_by_zero_message():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

pytest.raises(), used as a context manager, is the pytest equivalent of unittest's assertRaises — verifying that the code inside the block raises the expected exception type. The optional match= argument goes further, checking the exception's actual message against a regular expression — genuinely useful when you want to verify not just that some ValueError occurred, but specifically that the right one did, with the message you actually expect.

Mocking Dependencies and Useful Plugins

Isolating external dependencies

Exactly the same motivation covered in the earlier unittest.mock section applies here: code depending on a real database, email service, or external API is genuinely hard to unit test reliably and quickly. You can use unittest.mock directly with pytest — nothing about pytest prevents that — but the pytest-mock plugin offers a more pytest-idiomatic interface via its mocker fixture.

pip install pytest-mock
class UserService:
    def __init__(self, database, email_service):
        self.database = database
        self.email_service = email_service

    def register_user(self, name, email):
        self.database.save({"name": name, "email": email})
        self.email_service.send_welcome_email(email)
        return True
A practical example: UserService with mocked dependencies
def test_register_user(mocker):
    mock_database = mocker.Mock()
    mock_email_service = mocker.Mock()

    service = UserService(mock_database, mock_email_service)
    result = service.register_user("Alex", "[email protected]")

    assert result is True
    mock_database.save.assert_called_once_with({"name": "Alex", "email": "[email protected]"})
    mock_email_service.send_welcome_email.assert_called_once_with("[email protected]")

The mocker fixture (automatically available once pytest-mock is installed, no explicit import required beyond adding it as a test function parameter) provides mocker.Mock() and mocker.patch(), functioning essentially identically to unittest.mock's own Mock/patch, but integrated more naturally into pytest's fixture-based style — and, genuinely usefully, mocker-created patches are automatically undone at the end of each test, without needing an explicit @patch decorator or manual cleanup.

register_user() is tested here entirely in isolation — no real database, no real email service ever gets touched — while still verifying, via assert_called_once_with(), that the method interacted with both dependencies correctly: called exactly once, with exactly the expected arguments.

A brief tour of the wider plugin ecosystem
  • pytest-cov — generates code coverage reports, showing exactly which lines of your source code were (and weren't) actually executed by your test suite.

  • pytest-timeout — automatically fails any test that runs longer than a specified time limit, genuinely useful for catching an accidentally hanging test (say, one waiting on a mock that was never configured to return) before it silently stalls your entire CI pipeline.

  • pytest-asyncio — enables testing async def coroutines directly, correctly running them inside an event loop as part of the test — necessary given that a plain, synchronous test function can't directly await anything, tying back to the earlier asyncio article in this series.

Closing best practices

Follow the Arrange-Act-Assert structure, exactly as covered in the earlier introduction article — setup, action, verification, kept visually and logically distinct within each test, regardless of framework.

Keep fixtures focused and appropriately scoped. A fixture that tries to set up too many unrelated things at once becomes a liability — hard to reuse cleanly, and hard to reason about exactly what state a given test starts from. Prefer several small, focused fixtures over one large, do-everything fixture, and choose the narrowest scope that's still reasonably performant, as covered in Section 3.

Treat coverage percentage as a signal to investigate, not a target to chase. A high coverage number tells you which lines of code ran during your test suite — it says nothing at all about whether the assertions covering those lines are actually meaningful, or whether genuinely important edge cases and boundary conditions (covered in the earlier unittest deep-dive) are actually being tested. Chasing a specific coverage percentage as a goal in itself can lead to shallow, low-value tests written purely to touch a line of code, without genuinely verifying its behavior — coverage is a useful diagnostic for finding untested code you might have missed, not a proxy for genuine test quality on its own.