pytest 8 cleaned up node-id syntax, dropped Python 3.7, and made many warnings hard errors.
pyproject.toml is the canonical config home — pytest.ini /
setup.cfg still work but get less love. The ecosystem standard is
pytest-cov + pytest-xdist + pytest-mock + pytest-asyncio — install them together. This sheet pins to pytest 8+.
Install · runSetup
bash
# Install pytest + commonly-paired plugins
pip install "pytest>=8" pytest-cov pytest-xdist pytest-mock pytest-asyncio pytest-randomly
# Project layout (any of these — pytest auto-discovers)
# tests/test_*.py
# tests/*_test.py
# src//tests/...
#
# Recommended: src/ layout + tests/ at repo root + conftest.py for fixtures.
# Run it
pytest # all tests
pytest tests/test_api.py::test_login # one test
pytest -k "login and not slow" # name filter
pytest -m "smoke" # marker filter
pytest -x --ff # stop on first fail, retry failed-first
pytest -n auto # parallel (pytest-xdist)
pytest --cov=src --cov-report=html # coverage + HTML report
pytest --lf # last-failed
pytest -vv --tb=short # verbose + concise traceback
pytest --collect-only -q # show what would run
Where things liveCommon imports
import pytest
Entry point for marks, fixtures, raises, warns.
from pytest import fixture, mark, raises, warns, approx, MonkeyPatch
Convenience aliases.
from unittest.mock import patch, MagicMock, ANY, call
Stdlib mocks — pytest-mock’s mocker wraps these.
from pytest_mock import MockerFixture
Type hint for the mocker fixture.
import pytest_asyncio
Async fixtures + @pytest_asyncio.fixture.
from pathlib import Path / tmp_path: Path
Built-in tmp dir fixture.
assert · raises · approxWriting tests
def test_foo(): assert x == 1
Bare functions starting with test_. Preferred over class-based.
class TestUser: def test_create(self, db): ...
Class-based grouping. No unittest.TestCase needed.
assert actual == expected
Plain assert — pytest rewrites it for rich diffs.
assert {"a": 1} == {"a": 1, "b": 2}
Dict / list / set diffs render in the report — no assertEqual ceremony.
with pytest.raises(ValueError, match=r"already \w+"):
Expect an exception. match is a regex on str(exc).
Shared fixtures — auto-discovered for tests below the directory.
request.param / request.node / request.cls
Inside a fixture, read what asked for it.
request.getfixturevalue("name")
Resolve a fixture by name at runtime.
@pytest.fixture(name="alias")
Expose under a different name (avoid db-shadow problems).
python
# tests/conftest.py — fixtures auto-discovered for every test below this dir
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture(scope="session")
def engine():
e = create_engine("sqlite:///:memory:")
yield e
e.dispose() # teardown after the session
@pytest.fixture
def db(engine) -> Session: # function-scoped (default)
with Session(engine) as s: # rolled back between tests
yield s
@pytest.fixture(autouse=True)
def reset_clock(monkeypatch): # runs for every test in scope
monkeypatch.setattr("time.time", lambda: 1_700_000_000.0)
# Parametrize a fixture — every dependent test runs once per value
@pytest.fixture(params=[1, 2, 3], ids=["a", "b", "c"])
def n(request):
return request.param
# Indirect parametrize — let the fixture transform the value
@pytest.fixture
def user(request):
return {"plan": request.param}
@pytest.mark.parametrize("user", ["free", "pro"], indirect=True)
def test_billing(user):
assert user["plan"] in {"free", "pro"}
A typical test module: parametrized happy path, exception path, mocker for external calls, custom marker for the slow integration test.
python
# tests/test_users.py — parametrize, fixtures, raises, mocker, marker.
import pytest
from unittest.mock import ANY
from myapp.users import create_user, BillingError
@pytest.mark.smoke
@pytest.mark.parametrize("plan,fee", [
("free", 0),
("pro", 10),
("max", 50),
])
def test_create_user(db, plan, fee):
u = create_user(db, "ana@x.com", plan=plan)
assert u.id and u.fee == fee
def test_duplicate_email_rejected(db):
create_user(db, "ana@x.com")
with pytest.raises(ValueError, match="already exists"):
create_user(db, "ana@x.com")
def test_billing_error_logs(db, mocker): # pytest-mock fixture
log = mocker.patch("myapp.users.log.error")
mocker.patch("myapp.users.charge", side_effect=BillingError)
with pytest.raises(BillingError):
create_user(db, "ana@x.com", plan="pro")
log.assert_called_once_with("billing failed", user_id=ANY)
@pytest.mark.skipif("os.getenv('CI') is None",
reason="needs live Stripe key")
@pytest.mark.integration
def test_stripe_round_trip():
...
Best practiceGood to know
Turn on --strict-markers + --strict-config day one.
Typos in marker names silently no-op without strict mode — @pytest.mark.smoek would just disappear. Strict mode errors loudly and forces you to register markers in config.
Prefer mocker (pytest-mock) over with patch(...).
Same API as unittest.mock but auto-reverted at teardown and composes with other fixtures. Indentation-free tests, fewer leaked patches.
Use tmp_path, never tempfile.mkdtemp().tmp_path is per-test, auto-cleaned, reported on failures, and works with -n auto. Hand-rolled temp dirs leak when a test crashes.
Common trapsWatch out for
Session-scoped fixtures shared across tests = shared mutable state.
One test mutates the value, the next test sees it — usually order-dependent failures only on CI. Default to function scope; widen only when you measure the speedup.
conftest.py location matters.
Fixtures only apply to tests below their conftest.py. Defining one inside tests/api/ won’t reach tests/db/. Put truly-shared fixtures at the project root conftest.py.
Don’t patch where the object lives — patch where it’s used.mocker.patch("requests.get") doesn’t affect code that already did from requests import get. Patch myapp.client.get (the consuming module) instead.
pytest is the most popular Python testing framework. It discovers and runs test functions automatically, provides rich assertion introspection, and has a plugin ecosystem covering mocking, coverage, asyncio, Django, and more. Its fixture system replaces traditional setUp/tearDown with composable, reusable helpers.
What are pytest fixtures and how do they work?
A fixture is a function decorated with @pytest.fixture that sets up (and optionally tears down) test dependencies. Tests request fixtures by naming them as parameters: def test_user(db_session) triggers the db_session fixture automatically. Fixtures can have function, class, module, or session scope to control how often they run.
What is @pytest.mark.parametrize?
@pytest.mark.parametrize lets you run the same test with multiple input sets without duplicating the function. Decorate with @pytest.mark.parametrize('x,y', [(1,2),(3,4)]) and pytest generates separate test cases for each tuple. Combine multiple parametrize decorators for a cartesian product of inputs.
How do I mock in pytest?
Use the pytest-mock plugin, which provides a mocker fixture wrapping unittest.mock. Call mocker.patch('module.ClassName') to replace an object for the duration of the test — no manual cleanup needed. mocker.patch.object(instance, 'method') patches a specific method. The standard library's unittest.mock.patch also works as a decorator or context manager.
How do I test async code with pytest?
Install pytest-asyncio, add asyncio_mode = 'auto' to pytest.ini (or pyproject.toml), and write test functions with async def. The plugin manages the event loop. Mark specific tests with @pytest.mark.asyncio if not in auto mode. For httpx or aiohttp clients, pytest-httpx or aioresponses provide async-aware mocking.
Is pytest free and open source?
Yes. pytest is MIT-licensed and free to use. It is maintained by the community and is the default testing tool recommended by most Python packaging guides. All major plugins (pytest-mock, pytest-cov, pytest-asyncio) are also free and open source.