../

Testing

Testing Python 3.14 code with pytest 9: discovery, assertions, fixtures, parametrize, marks, mocking, async tests, coverage, Hypothesis and snapshots. Packaging and uv live in Packages & libraries; the TypeScript side is Testing.

Setup & layout

uv add --dev pytest pytest-cov pytest-mock
uv run pytest                  # all tests
uv run pytest tests/test_cart.py::TestAdd -q
uvx --with pytest-cov pytest   # no project; throwaway env

uv add --dev writes to [dependency-groups] dev, which uv sync and uv run install by default (pip install pytest in a venv is the equivalent).

src layout with tests
shop/pyproject.toml       # [tool.pytest], coveragesrc/shop/__init__.pycart.pytests/conftest.py      # fixtures for every testunit/test_cart.pyintegration/conftest.py  # DB fixtures, this dir onlytest_orders.py__snapshots__/   # syrupy output

With the src layout the package is importable only once installed, which uv sync does (editable), so tests exercise what users get. No __init__.py is needed in tests/ with --import-mode=importlib.

tests/unit/test_cart.py
import pytest
 
from shop.cart import Cart
 
 
def test_total_sums_lines() -> None:
    cart = Cart()
    cart.add("tea", price=4.5, qty=2)
    assert cart.total() == 9.0
 
 
class TestAdd:  # no __init__, so pytest collects it
    def test_rejects_zero_qty(self) -> None:
        with pytest.raises(ValueError, match="positive"):
            Cart().add("tea", 4.5, qty=0)

Discovery

WhatRuleSetting
Start pointsCLI args, else testpaths, else the current dirtestpaths
Directoriesrecursed, except .*, build, dist, venv, node_modules, *.egg and any virtualenvnorecursedirs
Filestest_*.py or *_test.pypython_files
Functionstest* at module levelpython_functions
ClassesTest* with no __init__; their test* methodspython_classes
conftest.pyloaded for its directory and everything below itnone
Config filefirst of pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg-c file
rootdirdirectory of the config file; node IDs are relative to it--rootdir

A node ID addresses one test: tests/unit/test_cart.py::TestAdd::test_rejects_zero_qty, with [param-id] appended for parametrized cases. uv run pytest --co -q lists them all.

--import-modeBehavior
prepend (default)inserts each test's root dir at the front of sys.path; test basenames must be unique without __init__.py
importlibimports test files without touching sys.path; duplicate basenames fine; tests can't import each other. Recommended for new projects
appendlike prepend but at the end, so installed packages win

Assertions

Plain assert. pytest rewrites asserts in test modules, conftest.py and plugins so a failure shows the intermediate values and a diff.

>       assert got == {"name": "ada", "roles": ["dev"]}
E       AssertionError: assert {'name': 'ada...s': ['admin']} == {'name': 'ada...les': ['dev']}
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'roles': ['admin']} != {'roles': ['dev']}

Asserts in helper modules are not rewritten unless you call pytest.register_assert_rewrite("tests.helpers") in the root conftest.py before importing them. Never run tests with python -O, which strips asserts.

tests/test_assert.py
import warnings
 
import pytest
 
from shop.net import parse_port
 
 
def test_raises_with_message() -> None:
    with pytest.raises(ValueError, match=r"range: \d+"):
        parse_port("70000")
 
 
def test_inspect_exception() -> None:
    with pytest.raises(ValueError) as exc_info:
        parse_port("http")
    assert "invalid literal" in str(exc_info.value)
    assert exc_info.type is ValueError
 
 
def test_floats() -> None:
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert [0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0])
    assert 103 == pytest.approx(100, rel=0.05)  # within 5 %
 
 
def test_warning() -> None:
    with pytest.warns(DeprecationWarning, match="v1"):
        warnings.warn("v1 is old", DeprecationWarning, 2)
 
 
def test_exception_group() -> None:
    with pytest.RaisesGroup(ValueError, TypeError):
        errors = [ValueError(), TypeError()]
        raise ExceptionGroup("batch", errors)
HelperChecks
pytest.raises(E, match=re)block raises E (or a subclass); match is re.search on str(exc)
excinfo.value / .type / .tracebackthe caught exception, after the with block
pytest.RaisesGroup(E1, E2)an ExceptionGroup with exactly those leaves (allow_unwrapped=True, flatten_subgroups=True)
pytest.approx(x, rel=, abs=)floats, sequences, dicts, NumPy arrays; default rel=1e-6
pytest.warns(W, match=re)block emits warning W
pytest.deprecated_call()block emits a DeprecationWarning or PendingDeprecationWarning
pytest.fail(msg) / pytest.skip(msg)fail or skip from inside a test or fixture

Fixtures

A fixture is a function that a test requests by parameter name. Code before yield is setup; code after it is teardown, which runs even when the test fails.

tests/conftest.py
import sqlite3
from collections.abc import Iterator
from pathlib import Path
 
import pytest
 
 
@pytest.fixture(scope="session")
def db_path(
    tmp_path_factory: pytest.TempPathFactory,
) -> Path:
    return tmp_path_factory.mktemp("db") / "test.sqlite3"
 
 
@pytest.fixture
def conn(db_path: Path) -> Iterator[sqlite3.Connection]:
    conn = sqlite3.connect(db_path)
    yield conn  # the test runs here
    conn.close()  # teardown runs even if the test failed
 
 
@pytest.fixture(autouse=True)
def _utc(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("TZ", "UTC")  # every test, no request
ScopeCreated once perUse for
function (default)testmutable state, anything a test might change
classtest classshared setup for a Test* class
moduletest fileexpensive read-only data
packagetest directoryper-package services
sessionwhole runDB engine, Docker container, app instance

A fixture can only request fixtures of the same or a wider scope (a session fixture can't use tmp_path; use tmp_path_factory). Fixtures in conftest.py are visible to every test below that directory with no import; a fixture with the same name closer to the test overrides it.

Factory fixtures

Return a function when a test needs several objects, or different ones.

tests/test_users.py
from dataclasses import dataclass
from typing import Protocol
 
import pytest
 
 
@dataclass(frozen=True)
class User:
    name: str
    admin: bool = False
 
 
class MakeUser(Protocol):
    def __call__(
        self, name: str = ..., *, admin: bool = ...
    ) -> User: ...
 
 
@pytest.fixture
def make_user() -> MakeUser:
    def make(
        name: str = "ada", *, admin: bool = False
    ) -> User:
        return User(name, admin)
 
    return make
 
 
def test_factory(make_user: MakeUser) -> None:
    admin = make_user("root", admin=True)
    assert admin.admin and not make_user().admin

Built-in fixtures

FixtureTypeGives you
tmp_pathpathlib.Pathfresh empty directory per test
tmp_path_factorypytest.TempPathFactory.mktemp(name) for session-scoped dirs
monkeypatchpytest.MonkeyPatchsetattr, setitem, setenv, delenv, chdir, syspath_prepend; undone after the test
capsys / capfdpytest.CaptureFixture[str].readouterr() of stdout/stderr (Python level / file descriptors)
caplogpytest.LogCaptureFixture.messages, .records, .record_tuples, .at_level()
recwarnpytest.WarningsRecorderevery warning raised in the test
requestpytest.FixtureRequest.param, .node, .addfinalizer(), .getfixturevalue()
subtestspytest.Subtestswith subtests.test(msg, **kw): reports each block separately (pytest 9)
pytestconfigpytest.ConfigCLI options, .getini()
cachepytest.Cachevalues that persist between runs in .pytest_cache
tests/test_builtins.py
import logging
import os
 
import pytest
 
log = logging.getLogger("shop")
 
 
def test_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("API_URL", "http://test")
    monkeypatch.delenv("HOME", raising=False)
    assert os.environ["API_URL"] == "http://test"
 
 
def test_stdout(capsys: pytest.CaptureFixture[str]) -> None:
    print("hello")
    out, err = capsys.readouterr()
    assert out == "hello\n" and err == ""
 
 
def test_logs(caplog: pytest.LogCaptureFixture) -> None:
    with caplog.at_level(logging.WARNING, logger="shop"):
        log.warning("low stock: %s", "tea")
    assert caplog.messages == ["low stock: tea"]
 
 
def test_subtests(subtests: pytest.Subtests) -> None:
    for n in (2, 4, 6):
        with subtests.test("even", n=n):
            assert n % 2 == 0  # each failure reported

@pytest.mark.usefixtures("clean_db") requests a fixture whose value the test doesn't need.

Parametrize

tests/test_params.py
from dataclasses import dataclass
 
import pytest
 
from shop.net import parse_port
 
 
@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("80", 80),
        ("65535", 65_535),
        pytest.param(" 443 ", 443, id="whitespace"),
        pytest.param(
            "0x50",
            80,
            marks=pytest.mark.xfail(raises=ValueError),
        ),
    ],
)
def test_parse(raw: str, expected: int) -> None:
    assert parse_port(raw) == expected
 
 
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_grid(x: int, y: int) -> None:  # 4 cases
    assert x < y
 
 
@dataclass(frozen=True)
class Conn:
    driver: str
 
 
@pytest.fixture
def conn(request: pytest.FixtureRequest) -> Conn:
    return Conn(driver=request.param)  # from parametrize
 
 
@pytest.mark.parametrize(
    "conn", ["sqlite", "postgres"], indirect=True
)
def test_indirect(conn: Conn) -> None:
    assert conn.driver in {"sqlite", "postgres"}
 
 
@pytest.fixture(params=["utf-8", "latin-1"], ids=str.upper)
def encoding(request: pytest.FixtureRequest) -> str:
    return str(request.param)  # users run once per param
 
 
def test_encode(encoding: str) -> None:
    assert "cafe".encode(encoding) == b"cafe"
test_params.py::test_parse[80-80]
test_params.py::test_parse[whitespace]
test_params.py::test_grid[10-1]
test_params.py::test_indirect[sqlite]
test_params.py::test_encode[UTF-8]
OptionEffect
ids=["a", "b"] or ids=fnreadable IDs, for -k and reports; fn gets each value
pytest.param(..., id=, marks=)ID or marks for a single case
stacked decoratorsCartesian product
indirect=True or indirect=["name"]value goes to the fixture as request.param instead of the test
@pytest.fixture(params=[...])parametrize every test that uses the fixture
scope="module"group cases so module-scoped fixtures aren't rebuilt

Marks

import sys
 
import pytest
 
 
@pytest.mark.skip(reason="flaky upstream")
def test_skipped() -> None: ...
 
 
@pytest.mark.skipif(sys.platform == "win32", reason="posix")
def test_posix_only() -> None: ...
 
 
@pytest.mark.xfail(reason="bug #42", strict=True)
def test_known_bug() -> None:
    assert int("1e3") == 1000
 
 
@pytest.mark.slow  # custom: register it in config
def test_big_import() -> None:
    np = pytest.importorskip("numpy")  # skip if missing
    assert np.zeros(3).sum() == 0
 
 
pytestmark = pytest.mark.integration  # whole module
MarkEffect
skip(reason=)never run; reported s
skipif(cond, reason=)skip when cond is true
xfail(reason=, raises=, strict=)expected failure x; an unexpected pass is X, or a failure with strict=True
usefixtures("name")request fixtures without parameters
filterwarnings("error::UserWarning")per-test warning filter
parametrize(...)see above
any other namecustom mark for -m; unknown names fail under strict
uv run pytest -m "not slow"
uv run pytest -m "integration and not slow"
uv run pytest -k "parse and not whitespace"  # name match

Configuration

pytest 9 reads native TOML from [tool.pytest] (lists are lists). The older [tool.pytest.ini_options] table, with string values, still works; use one or the other.

pyproject.toml
[tool.pytest]
minversion = "9.0"
testpaths = ["tests"]
addopts = ["-ra", "--import-mode=importlib"]
strict = true  # strict config, markers, xfail, param ids
markers = [
  "slow: takes more than a second",
  "integration: needs real services",
]
filterwarnings = [
  "error",  # warnings fail tests
  "ignore::DeprecationWarning:somelib.*",
]
log_level = "INFO"  # what caplog captures
asyncio_mode = "auto"  # pytest-asyncio, see below
KeyMeaning
strict = trueturns on strict_config, strict_markers, strict_xfail, strict_parametrization_ids; typos become errors
addoptsflags added to every run
testpathswhere to look when no path is given
pythonpath = ["src"]add dirs to sys.path (for projects that aren't installed)
filterwarningswarning filters, last match wins; "error" first is a good default
strict_xfailonly strict xfail, without the rest of strict (older name xfail_strict)
required_pluginsfail fast if a plugin ("pytest-cov>=7") is missing
console_output_styleprogress (default), count, classic

CLI flags

FlagDoes
-q / -v / -vvless / more output; -vv shows full diffs
-x, --maxfail=Nstop after the first / Nth failure
-k EXPRrun tests whose names match ("cart and not slow")
-m EXPRrun tests with matching marks
--lf / --ffonly last-failed / failed first, then the rest
--nfnew test files first
--swstepwise: stop at a failure, resume from it next run
-sdon't capture output (see print live)
-lshow local variables in tracebacks
--tb=short|line|notraceback style
-rasummary line for everything that didn't pass
--pdb / --tracedebugger on failure / at the start of each test
--durations=1010 slowest setups and tests
--cocollect only; list tests
-p no:NAMEdisable a plugin (-p no:randomly)
-W errorturn warnings into errors
-n autorun on all cores (pytest-xdist)
--runxfailrun xfail tests as normal tests

Mocking

unittest.mock is stdlib; pytest-mock wraps it as the mocker fixture, which undoes patches at test end. Use monkeypatch for plain attribute and env swaps without call recording.

Patch where the name is looked up, not where it's defined. If shop/report.py does from shop.clock import now, patch shop.report.now; patching shop.clock.now changes nothing that report sees.

tests/test_mocking.py
from datetime import UTC, datetime
from unittest.mock import AsyncMock, create_autospec, patch
 
import pytest
from pytest_mock import MockerFixture
 
from shop import report
from shop.notify import Mailer, welcome
 
FIXED = datetime(2026, 9, 25, tzinfo=UTC)
 
 
def test_patch_where_used() -> None:
    with patch("shop.report.now", return_value=FIXED):
        assert report.stamp() == "2026-09-25"
 
 
def test_autospec_checks_signature() -> None:
    with patch("shop.report.now", autospec=True) as now:
        now.return_value = FIXED
        report.stamp()
        with pytest.raises(TypeError):
            now("unexpected arg")  # real now() takes none
 
 
async def test_async_method() -> None:
    mailer = create_autospec(Mailer, instance=True)
    mailer.send.return_value = True  # async def → AsyncMock
    assert await welcome(mailer, "a@x.dev")
    mailer.send.assert_awaited_once_with(
        "a@x.dev", "Welcome!"
    )
 
 
async def test_side_effects() -> None:
    send = AsyncMock(side_effect=[True, OSError("down")])
    assert await send()
    with pytest.raises(OSError):
        await send()
 
 
def test_mocker(mocker: MockerFixture) -> None:
    now = mocker.patch("shop.report.now", return_value=FIXED)
    spy = mocker.spy(report, "stamp")  # real call, recorded
    report.stamp()
    now.assert_called_once_with()
    assert spy.spy_return == "2026-09-25"
APIUse
patch("pkg.mod.name")replace for a with block or decorated test; returns a MagicMock
patch.object(obj, "attr")patch an attribute of an object you already hold
patch.dict(os.environ, {...})temporarily change a dict
autospec=True / create_autospec(C)mock with the real signature; wrong calls raise TypeError, async def becomes AsyncMock
return_valuewhat a call returns
side_effectexception to raise, iterable of results, or a function to call
assert_called_once_with(...), assert_not_called()call checks; call_args, call_count, mock_calls to inspect
assert_awaited_once_with(...)the same for AsyncMock
ANY, call(...), sentinel.xwildcards and unique placeholders in assertions
mocker.patch, mocker.spy, mocker.stubpytest-mock equivalents, auto-undone

Prefer injecting dependencies (a client, a clock) over patching module globals; patching is for code you can't change.

Async tests

pytest can't run async def tests itself. Pick one plugin.

pytest-asyncio 1.xAnyIO plugin (ships with anyio)
Installuv add --dev pytest-asyncioalready there if anyio is installed
Mark@pytest.mark.asyncio@pytest.mark.anyio
No marksasyncio_mode = "auto"anyio_mode = "auto" (runs on asyncio only by default)
Async fixtures@pytest_asyncio.fixture in strict mode; plain @pytest.fixture in autoplain @pytest.fixture
Loop sharingloop_scope="module" on mark or fixturebackend fixture's scope
Backendsasyncioasyncio, trio (anyio_backend fixture)
tests/test_async.py (pytest-asyncio, strict mode)
import asyncio
from collections.abc import AsyncIterator
 
import pytest
import pytest_asyncio
 
 
@pytest_asyncio.fixture
async def queue() -> AsyncIterator[asyncio.Queue[int]]:
    q: asyncio.Queue[int] = asyncio.Queue()
    yield q
    q.shutdown()
 
 
@pytest.mark.asyncio
async def test_queue(queue: asyncio.Queue[int]) -> None:
    await queue.put(1)
    assert await queue.get() == 1
tests/test_anyio.py
from collections.abc import AsyncIterator
 
import anyio
import pytest
 
pytestmark = pytest.mark.anyio
 
 
@pytest.fixture
def anyio_backend() -> str:
    return "asyncio"  # params=["asyncio", "trio"] for both
 
 
@pytest.fixture
async def answer() -> AsyncIterator[int]:
    yield 42
 
 
async def test_sleep(answer: int) -> None:
    with anyio.fail_after(1):  # timeout
        await anyio.sleep(0)
    assert answer == 42

Auto mode assumes asyncio is your only async library; if both plugins are installed, keep pytest-asyncio in strict mode. Code that requested the event_loop fixture must move to loop_scope: pytest-asyncio 1.0 removed that fixture.

Coverage

uv add --dev pytest-cov
uv run pytest --cov --cov-report=term-missing
uv run pytest --cov --cov-report=html  # htmlcov/index.html
uv run pytest --cov --cov-report=xml   # CI upload
pyproject.toml
[tool.coverage.run]
branch = true  # count both sides of each if
source = ["shop"]
patch = ["subprocess"]  # measure child processes too
 
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 90
exclude_also = [
  "if TYPE_CHECKING:",
  "raise NotImplementedError",
  "@overload",
]
ToolNotes
pytest --covpytest-cov; --cov=shop to name the package, --cov-branch, --cov-fail-under=90
coverage run -m pytestcoverage.py directly; then coverage report, coverage html
branch coverageflags an if whose false side never ran, which line coverage misses
# pragma: no coverexclude a line or block

Coverage shows what ran, not what was checked. Aim for high branch coverage on logic and don't chase 100 % on glue code.

Property-based testing

Hypothesis generates inputs, runs the test many times and shrinks any failure to a minimal example, which it saves and replays next run.

tests/test_props.py
import json
 
from hypothesis import example, given, settings
from hypothesis import strategies as st
 
from shop.cart import Cart
 
json_values = st.recursive(
    st.none() | st.booleans() | st.integers() | st.text(),
    lambda kids: st.lists(kids)
    | st.dictionaries(st.text(), kids),
)
 
 
@given(json_values)
def test_json_roundtrip(value: object) -> None:
    assert json.loads(json.dumps(value)) == value
 
 
@given(st.lists(st.integers()))
@example([])  # always tried, on top of random cases
def test_sort_idempotent(xs: list[int]) -> None:
    assert sorted(sorted(xs)) == sorted(xs)
 
 
prices = st.floats(0, 1e6, allow_nan=False)
qtys = st.integers(min_value=1, max_value=99)
 
 
@settings(max_examples=500)
@given(st.lists(st.tuples(prices, qtys), max_size=20))
def test_total_never_negative(
    lines: list[tuple[float, int]],
) -> None:
    cart = Cart()
    for price, qty in lines:
        cart.add("sku", price, qty)
    assert cart.total() >= 0
StrategyGenerates
st.integers(min_value=, max_value=)ints
st.floats(allow_nan=False, allow_infinity=False)floats
st.text(), st.binary()str, bytes
st.lists(s, min_size=, max_size=, unique=)lists; also sets, tuples, dictionaries
st.sampled_from(seq), st.just(x), a | bchoice, constant, either
st.builds(Cls, field=s)instances from a callable
st.from_type(T)anything with type hints: dataclasses, TypedDict, pydantic models
s.map(f), s.filter(p)transform; filter sparingly
@st.compositehand-written strategy that draws from others

Good properties: round-trips (decode(encode(x)) == x), invariants (total never negative), idempotence, and agreement with a simpler reference implementation. @settings(deadline=None) stops slow tests being flagged; --hypothesis-show-statistics shows what ran.

Snapshot testing

syrupy compares a value with a stored snapshot. The first run with --snapshot-update writes it; later runs fail on any difference. Review snapshot diffs like code.

tests/test_snapshot.py
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.extensions.json import JSONSnapshotExtension
 
from shop.cart import Cart
from shop.invoice import render
 
 
@pytest.fixture
def snapshot_json(
    snapshot: SnapshotAssertion,
) -> SnapshotAssertion:
    return snapshot.use_extension(JSONSnapshotExtension)
 
 
def test_invoice(snapshot: SnapshotAssertion) -> None:
    cart = Cart()
    cart.add("tea", 4.5, 2)
    assert render(cart) == snapshot  # __snapshots__/*.ambr
 
 
def test_invoice_json(
    snapshot_json: SnapshotAssertion,
) -> None:
    assert render(Cart()) == snapshot_json  # one .json each
uv add --dev syrupy
uv run pytest --snapshot-update  # write or refresh
uv run pytest                    # compare; unused ones fail

Only snapshot deterministic output: freeze time, sort sets and strip random IDs first.

Testing FastAPI

TestClient calls the app in-process, with no server. Swap dependencies with app.dependency_overrides. More in FastAPI.

tests/test_api.py
from collections.abc import Iterator
 
import httpx2
import pytest
from fastapi.testclient import TestClient
 
from shop.api import app, get_store
 
 
def fake_store() -> dict[int, str]:
    return {1: "tea"}
 
 
@pytest.fixture
def client() -> Iterator[TestClient]:
    app.dependency_overrides[get_store] = fake_store
    with TestClient(app) as c:  # runs lifespan events
        yield c
    app.dependency_overrides.clear()
 
 
def test_read_item(client: TestClient) -> None:
    r = client.get("/items/1")
    assert r.status_code == 200
    assert r.json() == {"name": "tea"}
 
 
async def test_async_client() -> None:
    app.dependency_overrides[get_store] = fake_store
    transport = httpx2.ASGITransport(app=app)
    async with httpx2.AsyncClient(
        transport=transport, base_url="http://test"
    ) as ac:
        r = await ac.get("/items/1")
    app.dependency_overrides.clear()
    assert r.json() == {"name": "tea"}

Starlette's TestClient now runs on httpx2 (Pydantic's maintained continuation of httpx); with only httpx installed it still works but warns. Add httpx2 to the dev group. Use the async client when the test itself must await other things, such as a database.

Recipes

Temp project directory

When code reads or writes relative paths and must not touch the real working directory.

import json
from pathlib import Path
 
import pytest
 
 
@pytest.fixture
def project(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Path:
    cfg = {"port": 8080}
    (tmp_path / "config.json").write_text(json.dumps(cfg))
    monkeypatch.chdir(tmp_path)  # restored after the test
    return tmp_path
 
 
def test_reads_cwd_config(project: Path) -> None:
    cfg = json.loads(Path("config.json").read_text())
    assert cfg["port"] == 8080

Freeze time

When output depends on "now": expiry, timestamps, schedules. time-machine patches the C-level clock, so datetime.now, time.time and friends all agree.

from datetime import UTC, datetime, timedelta
 
import time_machine
from time_machine import TimeMachineFixture
 
from shop.report import stamp
 
 
def test_frozen(time_machine: TimeMachineFixture) -> None:
    time_machine.move_to(
        datetime(2026, 9, 25, 9, tzinfo=UTC), tick=False
    )
    assert stamp() == "2026-09-25"
    time_machine.shift(timedelta(days=1))
    assert stamp() == "2026-09-26"
 
 
@time_machine.travel(datetime(2000, 1, 1, tzinfo=UTC))
def test_decorator() -> None:
    assert datetime.now(UTC).year == 2000

Table-driven test

When one function has many input/output cases; a dict keyed by case ID keeps rows readable in reports.

import pytest
 
from shop.net import parse_port
 
type Row = tuple[str, int | None]  # None means "raises"
 
CASES: dict[str, Row] = {
    "http": ("80", 80),
    "max": ("65535", 65_535),
    "too-big": ("65536", None),
    "not-a-number": ("http", None),
}
 
 
@pytest.mark.parametrize(
    ("raw", "want"), CASES.values(), ids=CASES.keys()
)
def test_parse_port(raw: str, want: int | None) -> None:
    if want is None:
        with pytest.raises(ValueError):
            parse_port(raw)
    else:
        assert parse_port(raw) == want

Mock an HTTP call

When code calls an API through httpx; respx intercepts at the transport, so the real client code runs.

import httpx
import pytest
import respx
 
from shop.weather import temperature, temperature_sync
 
URL = "https://api.example.com/w/oslo"
 
 
async def test_ok(respx_mock: respx.MockRouter) -> None:
    body = {"temp": 4.5}
    route = respx_mock.get(URL).respond(200, json=body)
    async with httpx.AsyncClient() as client:
        assert await temperature(client, "oslo") == 4.5
    assert route.call_count == 1
 
 
def test_errors(respx_mock: respx.MockRouter) -> None:
    respx_mock.get(host="api.example.com").mock(
        side_effect=[httpx.Response(503), httpx.ConnectError]
    )
    with pytest.raises(httpx.HTTPStatusError):
        temperature_sync("oslo")
    with pytest.raises(httpx.ConnectError):
        temperature_sync("oslo")

Without respx: pass httpx.Client(transport=httpx.MockTransport(handler)) into the code under test.

DB session that rolls back

When tests hit a real database and each must start clean, even if the code calls commit().

from collections.abc import Iterator
 
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import Session
 
from shop.models import Base
 
 
@pytest.fixture(scope="session")
def engine() -> sa.Engine:
    engine = sa.create_engine("postgresql+psycopg://…/test")
    Base.metadata.create_all(engine)
    return engine
 
 
@pytest.fixture
def session(engine: sa.Engine) -> Iterator[Session]:
    with engine.connect() as conn, conn.begin() as tx:
        with Session(
            bind=conn,
            join_transaction_mode="create_savepoint",
        ) as s:
            yield s  # commit() only releases a SAVEPOINT
        tx.rollback()

For SQLite (sqlite3), pass connect_args={"autocommit": False} so SAVEPOINTs work.

Run only failed or affected tests

When iterating on a fix and the full suite is slow.

uv run pytest --lf           # last failed only
uv run pytest --ff -x        # failed first, stop at one
uv run pytest --sw           # fix one at a time, resume
uv run pytest -n auto        # pytest-xdist, all cores
# only tests whose code changed (pytest-testmon)
uv run --with pytest-testmon pytest --testmon
# re-run on save (pytest-watcher)
uv run --with pytest-watcher ptw . --now

Assert a warning or log was emitted

When deprecations and error logs are part of the contract.

import logging
import warnings
 
import pytest
 
 
def old_api() -> int:
    warnings.warn("use new_api()", DeprecationWarning, 2)
    logging.getLogger("shop").error("old_api called")
    return 1
 
 
def test_warns_and_logs(
    caplog: pytest.LogCaptureFixture,
) -> None:
    with pytest.warns(DeprecationWarning, match="new_api"):
        assert old_api() == 1
    record = ("shop", logging.ERROR, "old_api called")
    assert record in caplog.record_tuples
 
 
def test_no_warnings() -> None:
    with warnings.catch_warnings():
        warnings.simplefilter("error")  # any warning fails
        int("80")

References