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
# 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
# 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)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_999The 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:
# 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# 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_900And the rule that makes patching safe:
# 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 keywordUse autospec=True (or create_autospec) everywhere. Without it, a
refactor that renames a method leaves every test passing.
#Better than patching: pass it in
# 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_900The same point as dependency injection, in the language where it is easiest to avoid and therefore most often skipped.
#Time
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
# 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:
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 <= subtotalHypothesis 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
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# 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 ↗- Python
python/tests/languages/python
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- pytestPython's test framework — plain assert, the fixture model that makes it different, parameterisation, and the plugins worth having.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.
- Test Data ManagementWhere a test's data comes from decides whether the suite can run in parallel, twice in a row, or at all — builders, factories, fixtures and per-test isolation.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Writing Tests in TypeScriptThe TypeScript testing toolchain — Jest or Vitest, typed doubles, async idioms, and the type-level tricks that make tests both safer and more readable.
- Coverage MetricsLine, statement, branch, condition and path coverage — what each measures, why branch coverage is worth three times line coverage, and what none of them see.