pytest-testing

pytest testing patterns for any Python project: test structure, fixtures, parametrize, conftest.py, monkeypatch, tmp_path, markers, unittest.mock (MagicMock, patch), pytest-cov coverage, and best prac

SKILL.md

GitHub

pytest Testing Patterns (stack-agnostic)

pytest is the standard test runner for modern Python projects. These patterns apply to any Python 3.10+ codebase regardless of framework.

Project layout

Keep tests outside the source tree so installed packages are tested rather than the raw source directory:

src/
  myapp/
    __init__.py
    service.py
    repository.py
tests/
  conftest.py
  test_service.py
  test_repository.py
  integration/
    conftest.py
    test_db.py
pyproject.toml

Configure pytest to find the source in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers -q"

Install the package in editable mode before running tests so import myapp resolves correctly:

pip install -e .        # or: poetry install / uv sync
pytest

conftest.py — shared fixtures

conftest.py files are loaded automatically by pytest. Place shared fixtures there instead of importing them into every test module.

# tests/conftest.py
from __future__ import annotations

import pytest

from myapp.database import Database, get_connection
from myapp.service import UserService


@pytest.fixture(scope="module")
def db() -> Database:
    """Module-scoped DB connection — created once per test module."""
    conn = get_connection(url="sqlite:///:memory:")
    conn.create_tables()
    yield conn
    conn.close()


@pytest.fixture
def user_service(db: Database) -> UserService:
    """Function-scoped service — fresh instance per test, shared DB."""
    return UserService(db)

Fixture scopes (from broadest to narrowest):

Scope Created Destroyed
session Once per pytest run After all tests
module Once per test module After last test in module
class Once per test class After last test in class
function (default) Once per test function After the test returns

Use the narrowest scope that avoids expensive re-creation. Use session scope only for truly global state (e.g., a Docker container started via pytest-docker).

Basic test patterns

# tests/test_service.py
from __future__ import annotations

import pytest

from myapp.service import UserService
from myapp.exceptions import NotFoundError


def test_create_user_returns_new_id(user_service: UserService) -> None:
    user_id = user_service.create(name="Alice", email="alice@example.com")
    assert user_id > 0


def test_find_user_by_id(user_service: UserService) -> None:
    user_id = user_service.create(name="Bob", email="bob@example.com")
    user = user_service.find(user_id)
    assert user.name == "Bob"
    assert user.email == "bob@example.com"


def test_find_missing_user_raises(user_service: UserService) -> None:
    with pytest.raises(NotFoundError):
        user_service.find(99999)


def test_find_missing_user_message(user_service: UserService) -> None:
    with pytest.raises(NotFoundError, match="User.*99999"):
        user_service.find(99999)

Use plain assert — pytest rewrites assertions to produce detailed failure messages. Do not use assertEqual / assertTrue / assertRaises (those are unittest.TestCase methods).

Fixtures — yield, teardown, autouse

Use yield in a fixture to run teardown code after the test:

@pytest.fixture
def temp_user(user_service: UserService) -> int:
    user_id = user_service.create(name="Temp", email="temp@example.com")
    yield user_id
    user_service.delete(user_id)   # teardown: always runs even if test fails

Fixtures can depend on other fixtures — pytest resolves the dependency graph automatically:

@pytest.fixture
def admin_user(user_service: UserService) -> User:
    user_id = user_service.create(name="Admin", email="admin@example.com")
    return user_service.promote_to_admin(user_id)

Use autouse=True for cross-cutting setup that every test in a module or package needs:

# tests/integration/conftest.py
@pytest.fixture(autouse=True)
def rollback_after_test(db: Database) -> None:
    """Wrap every integration test in a transaction that rolls back."""
    db.begin()
    yield
    db.rollback()

parametrize — data-driven tests

Drive many input/output combinations through a single test body:

import pytest


@pytest.mark.parametrize("amount,currency,expected_str", [
    (100, "USD", "1.00 USD"),
    (0,   "EUR", "0.00 EUR"),
    (999, "GBP", "9.99 GBP"),
])
def test_money_format(amount: int, currency: str, expected_str: str) -> None:
    money = Money(amount, currency)
    assert str(money) == expected_str

Use pytest.param to give test cases readable IDs and to mark individual cases:

@pytest.mark.parametrize("raw,expected", [
    pytest.param("draft",    Status.DRAFT,    id="draft"),
    pytest.param("active",   Status.ACTIVE,   id="active"),
    pytest.param("archived", Status.ARCHIVED, id="archived"),
])
def test_status_parsing(raw: str, expected: Status) -> None:
    assert Status(raw) == expected


@pytest.mark.parametrize("raw", [
    pytest.param("unknown", id="unknown-value"),
    pytest.param("",        id="empty-string"),
])
def test_status_invalid(raw: str) -> None:
    with pytest.raises(ValueError):
        Status(raw)

Multiple independent parameters are passed as separate arguments — pytest generates all combinations:

@pytest.mark.parametrize("x", [0, 1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_add(x: int, y: int) -> None:
    assert add(x, y) == x + y   # 6 test cases generated

monkeypatch — patching at test time

monkeypatch is a pytest fixture that temporarily modifies attributes, environment variables, or dictionary entries and automatically restores them after the test.

import requests


def get_user_count(api_url: str) -> int:
    response = requests.get(f"{api_url}/users/count")
    response.raise_for_status()
    return response.json()["count"]


def test_get_user_count(monkeypatch: pytest.MonkeyPatch) -> None:
    class FakeResponse:
        def raise_for_status(self) -> None: ...
        def json(self) -> dict:
            return {"count": 42}

    monkeypatch.setattr(requests, "get", lambda url: FakeResponse())

    count = get_user_count("https://api.example.com")
    assert count == 42

Other common monkeypatch uses:

def test_reads_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
    assert get_database_url() == "sqlite:///:memory:"


def test_missing_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("DATABASE_URL", raising=False)
    with pytest.raises(EnvironmentError):
        get_database_url()


def test_patches_dict(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setitem(config, "timeout", 5)
    assert effective_timeout() == 5

Prefer monkeypatch over unittest.mock.patch for patching attributes and environment variables in pytest — it integrates with pytest's fixture lifecycle and does not require a context manager or decorator.

unittest.mock — mocking objects and calls

Use unittest.mock when you need to assert that a collaborator was called with specific arguments, or to make a mock raise an exception.

from unittest.mock import MagicMock, patch

from myapp.service import OrderService


def test_sends_confirmation_email() -> None:
    mailer = MagicMock()
    gateway = MagicMock()
    gateway.charge.return_value = Receipt(id="rcpt_1", amount=500)

    service = OrderService(payment_gateway=gateway, mailer=mailer)
    service.checkout(order=make_order(total_cents=500))

    gateway.charge.assert_called_once_with(500)
    mailer.send.assert_called_once()


def test_checkout_raises_on_payment_failure() -> None:
    gateway = MagicMock()
    gateway.charge.side_effect = PaymentDeclinedError("insufficient funds")

    service = OrderService(payment_gateway=gateway, mailer=MagicMock())

    with pytest.raises(PaymentDeclinedError, match="insufficient funds"):
        service.checkout(order=make_order(total_cents=500))

Use patch as a context manager when you need to replace a name in a module's namespace:

from unittest.mock import patch


def test_sends_welcome_email() -> None:
    with patch("myapp.service.send_email") as mock_send:
        create_user(name="Alice", email="alice@example.com")
        mock_send.assert_called_once_with(
            to="alice@example.com",
            subject="Welcome",
        )

Or as a decorator:

@patch("myapp.service.send_email")
def test_email_on_signup(mock_send: MagicMock) -> None:
    create_user(name="Bob", email="bob@example.com")
    assert mock_send.call_count == 1

Patch the name where it is used, not where it is defined. If myapp.service imports send_email from myapp.email, patch myapp.service.send_email, not myapp.email.send_email.

tmp_path — tests that write files

Use the built-in tmp_path fixture (a pathlib.Path pointing to a unique temporary directory) for tests that need to read or write files. pytest cleans it up after the session.

from pathlib import Path
import pytest

from myapp.export import write_csv


def test_write_csv_creates_file(tmp_path: Path) -> None:
    output = tmp_path / "report.csv"
    rows = [{"name": "Alice", "score": "95"}, {"name": "Bob", "score": "87"}]

    write_csv(output, rows)

    assert output.exists()
    lines = output.read_text(encoding="utf-8").splitlines()
    assert lines[0] == "name,score"
    assert len(lines) == 3   # header + 2 data rows


def test_load_config_from_file(tmp_path: Path) -> None:
    config_file = tmp_path / "config.toml"
    config_file.write_text('[app]\nport = 8080\n', encoding="utf-8")

    config = load_config(config_file)

    assert config.port == 8080

tmp_path is function-scoped by default. Use tmp_path_factory (session fixture) if multiple fixtures or tests need to share the same temporary directory.

Coverage — measuring and enforcing

Run pytest with pytest-cov:

pytest --cov=src --cov-report=term-missing

Configure coverage in pyproject.toml:

[tool.coverage.run]
source = ["src"]
branch = true               # track branch coverage, not just line coverage

[tool.coverage.report]
fail_under = 80
show_missing = true
omit = ["src/myapp/migrations/*", "src/myapp/__main__.py"]

With fail_under = 80, pytest --cov=src exits non-zero if coverage drops below 80% — useful as a CI gate.

Generate an HTML report for local inspection:

pytest --cov=src --cov-report=html
open htmlcov/index.html

Coverage is a floor, not a goal. A covered line is not necessarily an asserted behaviour — 100% coverage does not mean 0 bugs. Focus on asserting meaningful behaviours; the coverage number confirms you did not miss entire code paths.

Running tests

pytest                          # run all tests
pytest -x                       # stop on first failure
pytest -v                       # verbose: show each test name
pytest -q                       # quiet: summary only
pytest -k "test_create"         # run tests whose name matches the expression
pytest tests/test_service.py    # run a specific module
pytest tests/integration/       # run a subdirectory
pytest --tb=short               # shorter tracebacks
pytest --lf                     # run only tests that failed last time
pytest -n auto                  # parallel execution (requires pytest-xdist)

Run the full quality pipeline before committing:

ruff format --check . && ruff check . && mypy src/ && pytest --cov=src