Overview
pytest collects functions named test_* in files named test_*.py (or *_test.py), runs them, and rewrites plain assert statements so a failure shows both sides of the comparison. Fixtures provide setup and teardown, parametrize runs one test over many inputs, and plugins add async support, coverage, mocking and parallel runs. This skill targets pytest 9.x (checked against 9.1) with pytest-asyncio 1.x.
Instructions
Step 1: Install and configure
python -m venv .venv && source .venv/bin/activate
pip install pytest pytest-asyncio pytest-cov pytest-mock pytest-xdist
Put configuration in pyproject.toml ([tool.pytest.ini_options]) or, since pytest 9, in a native pytest.toml:
# pytest.toml
[pytest]
pythonpath = ["."] # lets tests import top-level modules such as `app`
testpaths = ["tests"]
asyncio_mode = "auto" # pytest-asyncio: no @pytest.mark.asyncio on every test
markers = ["slow: tests that take more than a second"]
strict = true # unknown markers and options become errors
Without pythonpath (or an installed package), from app import ... fails with ModuleNotFoundError when pytest is run from the project root.
Step 2: Basic tests
# tests/test_users.py
import pytest
from app.services.users import create_user, validate_email
def test_create_user_returns_user_object():
user = create_user(name="Priya Nair", email="priya@acme.io")
assert user.name == "Priya Nair"
assert user.id is not None
def test_validate_email_rejects_invalid():
assert validate_email("not-an-email") is False
assert validate_email("user@") is False
class TestUserService:
"""A class only groups tests; no base class or self.assert* methods needed."""
def test_duplicate_email_raises(self):
create_user(name="Priya Nair", email="priya@acme.io")
with pytest.raises(ValueError, match="Email already exists"):
create_user(name="Tom Berg", email="priya@acme.io")
Step 3: Fixtures
# tests/conftest.py — shared by every test in this directory and below
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.models import Base, User
@pytest_asyncio.fixture # async fixtures need this decorator in strict mode
async def db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session # code after yield is the teardown
await engine.dispose()
@pytest_asyncio.fixture
async def sample_user(db):
user = User(name="Priya Nair", email="priya@acme.io", role="member")
db.add(user)
await db.commit()
return user
@pytest.fixture
def api_client(db):
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db
app.dependency_overrides[get_db] = lambda: db
yield TestClient(app)
app.dependency_overrides.clear()
That example needs pip install "sqlalchemy[asyncio]" aiosqlite. Built-in fixtures worth knowing: tmp_path (a fresh directory per test), monkeypatch (set env vars and attributes, undone automatically), capsys and caplog (captured output and log records). A fixture's scope can be function (default), class, module, package or session; widen it only for expensive, read-only resources.
Step 4: Parametrize
@pytest.mark.parametrize(
"plan,expected_price",
[("free", 0), ("starter", 29), ("pro", 79), pytest.param("enterprise", 199, id="enterprise-list-price")],
)
def test_calculate_price(plan, expected_price):
assert calculate_price(plan) == expected_price
pytest 9 also ships subtests (a subtests fixture) for looping over cases inside one test without stopping at the first failure:
def test_slugify_variants(subtests):
for raw, slug in [("Hello World", "hello-world"), (" Spaces ", "spaces")]:
with subtests.test(raw=raw):
assert slugify(raw) == slug
Step 5: Mocking
# tests/test_notifications.py — pytest-mock provides the `mocker` fixture
from unittest.mock import AsyncMock
async def test_send_welcome_email(mocker, sample_user):
send = mocker.patch("app.services.email.send_email", new_callable=AsyncMock,
return_value={"id": "msg_8841"})
result = await send_welcome_email(sample_user.id)
send.assert_awaited_once_with(to="priya@acme.io", subject="Welcome!", template="welcome")
assert result["id"] == "msg_8841"
Patch the name where it is looked up (app.services.notifications.send_email, if that module did from ... import send_email), not where it is defined.
Step 6: Run
pytest # everything under testpaths
pytest tests/test_users.py::test_validate_email_rejects_invalid # one test
pytest -x --lf # stop at first failure; rerun last failures
pytest -k "create and not duplicate" # select by expression
pytest -m "not slow" # select by marker
pytest --cov=app --cov-report=term-missing # coverage (pytest-cov)
pytest -n auto # parallel workers (pytest-xdist)
pytest -ra # summary of skipped/xfailed reasons
Examples
Example 1: Add tests for a pricing function
User request: "Write pytest tests for calculate_discount in app/pricing.py, including bad input."
# tests/test_pricing.py
import pytest
from app.pricing import calculate_discount
@pytest.mark.parametrize("total,code,expected", [(100.0, "SPRING10", 90.0), (100.0, None, 100.0), (0.0, "SPRING10", 0.0)])
def test_discount_applies(total, code, expected):
assert calculate_discount(total, code) == pytest.approx(expected)
def test_unknown_code_raises():
with pytest.raises(ValueError, match="Unknown discount code"):
calculate_discount(50.0, "NOPE")
Run pytest tests/test_pricing.py -q; expect 4 passed (three parametrized cases plus the error test).
Example 2: Fix "async def functions are not natively supported"
User request: "My async tests fail with 'async def functions are not natively supported'."
pytest does not run coroutines on its own. Install the plugin and set the mode:
pip install pytest-asyncio
Add asyncio_mode = "auto" to pytest.toml, or keep the default strict mode and decorate each test with @pytest.mark.asyncio and each async fixture with @pytest_asyncio.fixture. Re-run pytest -q: the tests now pass.
Guidelines
- Use plain
assert; usepytest.approxfor floats andpytest.raises(..., match=...)for exceptions. - In strict asyncio mode (the default), an async fixture declared with plain
@pytest.fixtureerrors with "requested an async fixture ... no plugin or hook that handled it"; use@pytest_asyncio.fixtureor switch toasyncio_mode = "auto". - Fixtures with
yieldclean up even when the test fails; no try/finally needed. - Keep tests independent: no reliance on order, shared mutable module state or a leftover database. In-memory SQLite suits unit tests; use the real database engine for integration tests.
- Register custom markers in configuration; with
strict = true(or--strict-markers) a typo fails at collection instead of silently deselecting tests. - Parallel runs (
-n auto) need tests that do not share files, ports or database rows. - Do not mock what you own and can run cheaply; mock network calls, clocks and paid APIs.
unittest.TestCaseclasses also run under pytest, but fixtures and parametrize do not apply to their methods.