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) == expected

Add 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.