Overview
Use pytest for every Python test suite and avoid unittest.TestCase. pytest 9 added native TOML configuration, a strict mode, and subtests, and dropped Python 3.9. Turn on strict mode, scope fixtures to the narrowest lifetime, parametrize instead of looping, and prefer monkeypatch and fakes over MagicMock. Language-agnostic rules (determinism, test names, flakes) are in testing.
Configure pytest natively and turn on strict mode
[tool.pytest] (pytest 9+) takes real TOML lists. [tool.pytest.ini_options] still works but needs string values. strict = true enables strict_config, strict_markers, strict_parametrization_ids, and strict_xfail, so a misspelled marker or option fails the run instead of being ignored.
[tool.pytest]
minversion = "9.0"
strict = true
testpaths = ["tests"]
addopts = ["--cov=src", "--cov-report=term-missing", "--cov-fail-under=85"]Coverage is a floor, not a target: choose a number your suite already clears and fail CI below it.
Scope fixtures to the narrowest lifetime
Fixtures run per function by default; widen to module or session only for costly, read-only setup. Annotate generator fixtures with Iterator[T], not T.
from collections.abc import Iterator
@pytest.fixture(scope="session")
def db() -> Iterator[Database]:
conn = Database.connect(":memory:")
conn.migrate()
yield conn
conn.close()
@pytest.fixture
def clean_db(db: Database) -> Iterator[Database]:
with db.transaction() as tx:
yield db
tx.rollback()Limit autouse=True to universal preconditions such as pinning an environment variable; widespread autouse hides which test depends on what.
Parametrize instead of looping
@pytest.mark.parametrize gives each case its own pass or fail and its own ID. Add ids=[...] when the default IDs are unreadable. Use subtests (pytest 9) when the cases are only known at run time.
@pytest.mark.parametrize("n, expected", [(0, 1), (1, 1), (5, 120)], ids=["zero", "one", "five"])
def test_factorial(n: int, expected: int) -> None:
assert factorial(n) == expectedAdd Hypothesis for parsers and invariants
Hypothesis generates many inputs, shrinks a failure to a minimal case, and remembers it. It pays off for encoders, parsers, and mathematical properties that parametrize misses.
@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs: list[int]) -> None:
assert sorted(sorted(xs)) == sorted(xs)Prefer monkeypatch and fakes over MagicMock
monkeypatch sets attributes and environment variables and reverts them after the test.
def test_reads_db_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DB_URL", "sqlite:///:memory:")
assert load_config().db_url == "sqlite:///:memory:"Reach for unittest.mock only to assert how something was called, and create it with create_autospec or patch(..., autospec=True) so a signature change breaks the test instead of passing silently. Prefer an in-memory fake of a collaborator over any mock.
Test async code
Install pytest-asyncio and set asyncio_mode = "auto"; see python-async.