Skip to content
End To End Tester
Languagespractical

Writing Tests in Python

pytest fixtures, the patching rules that catch everyone, async testing, and the toolchain for a Python project that has to hold up in CI.

1 min read · updated 19 September 2026

Python's dynamism makes almost anything substitutable, which is a gift and a trap: it is easy to patch your way to a test that proves nothing.

#The toolchain

toml
# pyproject.toml
[tool.pytest.ini_options]
addopts = "-q --strict-markers --strict-config --cov=src --cov-report=term-missing"
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
    "integration: needs a database or a container",
    "slow: takes more than a second",
]

[dependency-groups]
test = [
    "pytest>=8.3",
    "pytest-cov",
    "pytest-xdist",
    "pytest-randomly",
    "pytest-asyncio",
    "freezegun",
    "testcontainers[postgres]",
    "respx",
]

--strict-markers turns a typo'd marker into an error rather than a silently ignored decorator. pytest-randomly shuffles the order, which surfaces test interdependence before parallelism does.

#Fixtures

python
# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16-alpine") as container:
        run_migrations(container.get_connection_url())
        yield container

@pytest.fixture
def connection(postgres):
    conn = psycopg.connect(postgres.get_connection_url())
    with conn.transaction(force_rollback=True):   # never committed
        yield conn
    conn.close()

@pytest.fixture
def repository(connection):
    return OrderRepository(connection)
python
def test_round_trips_money_without_losing_precision(repository):
    repository.save(Order(reference="REF-1", amount_cents=199_999))

    assert repository.find("REF-1").amount_cents == 199_999

The composition — repository needs connection needs postgres, each with its own lifetime — is what makes pytest different from a setUp method. See pytest.

#Patching, correctly

The rule that catches everyone:

python
# src/checkout/service.py
from rates.client import fetch_rates          # a reference is bound HERE

def quote(order):
    return fetch_rates()["GBP"] * order.total_cents
python
# Does nothing: rebinds the name in rates.client, not in checkout.service.
@patch("rates.client.fetch_rates")

# Correct: rebinds the name the module under test is actually using.
@patch("checkout.service.fetch_rates")
def test_quote_uses_the_live_rate(fetch_rates):
    fetch_rates.return_value = {"GBP": 0.79}

    assert quote(Order(total_cents=10_000)) == 7_900

And the rule that makes patching safe:

python
# A plain Mock accepts any call, including a typo.
gateway = Mock()
gateway.chrage(cents=100)        # passes. Silently.

# autospec builds the double from the real signature.
gateway = create_autospec(PaymentGateway, instance=True)
gateway.chrage(cents=100)        # AttributeError, as it should be
gateway.charge(cent=100)         # TypeError: unexpected keyword

Use autospec=True (or create_autospec) everywhere. Without it, a refactor that renames a method leaves every test passing.

#Better than patching: pass it in

python
# Untestable without patching
def quote(order):
    rates = requests.get("https://api.example.com/rates").json()
    return rates["GBP"] * order.total_cents

# Testable with a two-line stub
def quote(order, rates_source=live_rates):
    return rates_source()["GBP"] * order.total_cents

def test_quote_uses_the_supplied_rate():
    assert quote(Order(total_cents=10_000), rates_source=lambda: {"GBP": 0.79}) == 7_900

The same point as dependency injection, in the language where it is easiest to avoid and therefore most often skipped.

#Time

python
from freezegun import freeze_time

@freeze_time("2026-01-01 13:00:00")
def test_a_token_expires_exactly_at_its_expiry():
    assert is_expired(Token(expires_at=datetime(2026, 1, 1, 13, 0, tzinfo=timezone.utc)))

# Or inject a clock, which is preferable where you control the code.
def test_with_an_injected_clock():
    clock = lambda: datetime(2026, 1, 1, 13, 0, tzinfo=timezone.utc)
    assert is_expired(token, now=clock)

#Async

python
# asyncio_mode = "auto" means no decorator is needed.
async def test_retries_once_on_503(respx_mock):
    route = respx_mock.get("https://api.example.com/rates")
    route.side_effect = [httpx.Response(503), httpx.Response(200, json={"GBP": 0.79})]

    assert await fetch_gbp_rate() == 0.79
    assert route.call_count == 2


# AsyncMock, not Mock — a plain Mock returns a Mock, not a coroutine.
async def test_charges_the_order():
    gateway = AsyncMock(spec=PaymentGateway)
    gateway.charge.return_value = ChargeResult(ok=True, id="pi_1")

    await Checkout(gateway).pay(order)

    gateway.charge.assert_awaited_once()

#Property-based testing

Python has the best property-testing library of the four languages here:

python
from hypothesis import given, strategies as st

@given(
    subtotal=st.integers(min_value=0, max_value=10_000_000),
    percent=st.integers(min_value=0, max_value=100),
)
def test_a_discount_is_never_more_than_the_subtotal(subtotal, percent):
    result = apply_discount(Order(subtotal), DiscountPolicy(0, percent))

    assert 0 <= result.discount_cents <= subtotal

Hypothesis generates hundreds of cases, shrinks any failure to the minimal reproducing input, and remembers it. It is exceptionally good at finding boundary bugs in exactly the arithmetic that example-based tests cover only at the three values somebody thought of.

#Running it

bash
pytest                              # everything
pytest -m "not integration"         # the fast suite
pytest -n auto --dist loadfile      # parallel
pytest --lf                         # last failed
pytest --durations=10               # the ten slowest
bash
# Across versions, with tox or nox
tox -e py311,py312,py313

#What Python makes easy, and what it makes dangerous

Easy: substituting anything, fixtures that compose, parameterisation, property testing, and readable tests with no assertion vocabulary.

Dangerous: the same substitutability. A test suite held together by @patch decorators is coupled to the import structure of the code, breaks on every refactor, and passes when the real objects no longer fit together. Prefer passing dependencies in; keep patching for the edges you do not own.

Common questions

Why does my patch not take effect?
You patched where the function is defined rather than where it is used. Patching replaces a name in a namespace, and the module under test holds its own reference from its import. Patch "module_under_test.fetch_rates", not "rates_client.fetch_rates".
Should I use unittest or pytest?
pytest. It runs unittest-style tests unchanged, so there is no migration cost, and its fixtures, parameterisation and assertion introspection are all substantially better.
How do I test async code?
pytest-asyncio with asyncio_mode = "auto" in the config, after which an async def test just works. Use AsyncMock for async doubles — a plain Mock returns a Mock rather than a coroutine and produces confusing failures.

Runnable samples for this page

last test results ↗
  • Pythonpython/tests/languages/python

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?