pytest / Chapter 2 of 4

Test suites, async tests, and mocking

Organize pytest suites with conftest.py, async fixtures, and plugins. Test HTTP and database boundaries and patch dependencies safely.

5 topicsUpdated
13 / 26

Test layout and shared setup#

Put tests near related behavior and keep fixture visibility predictable.

A conftest.py makes fixtures available in its directory and child directories. A tests/unit/conftest.py does not affect sibling tests/integration tests. Tests can import ordinary Python helpers, but fixture discovery is by fixture name. Use --fixtures-per-test to see which fixtures each collected test uses.

TextA practical directory layout
project/
  pyproject.toml
  app/
    billing.py
  tests/
    conftest.py          # shared client and factories
    unit/
      test_billing.py
    integration/
      conftest.py        # DB fixture only for integration tests
      test_checkout.py

For deterministic tests, isolate external systems and clean up state. Avoid sharing a mutable global or a session-scoped database transaction across tests. Organize by feature and test layer; unit tests assert pure behavior, integration tests verify real boundaries.

ShellSee collection and fixture visibility
python -m pytest --collect-only -q tests/
python -m pytest --fixtures-per-test tests/unit/test_billing.py
python -m pytest --setup-plan tests/integration/

An ordinary Test* class groups tests and can use fixtures in methods. Do not add __init__ to the class; pytest cannot collect such a test class normally.

PythonGroup related cases
class TestTotals:
    def test_empty(self):
        assert sum([]) == 0

    def test_multiple(self, cart):
        cart["items"] = [1, 2, 3]
        assert sum(cart["items"]) == 6
14 / 26

Async tests and fixtures#

pytest core does not run async def tests by itself; install an async test plugin.

pytest-asyncio runs asyncio tests; pytest.mark.asyncio marks a coroutine test in strict mode. In auto mode it discovers asyncio tests automatically. In strict mode, decorate async fixtures with @pytest_asyncio.fixture. Use AsyncMock for awaited dependencies and assert that the awaited call happened.

ShellInstall and run asyncio support
python -m pip install pytest-asyncio
python -m pytest tests/test_async_jobs.py -q
python -m pytest --asyncio-mode=auto
PythonAsync workflow and async fixture
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock


@pytest_asyncio.fixture
async def inbox():
    queue = AsyncMock()
    queue.receive.return_value = {"id": 11, "attempts": 0}
    return queue


@pytest.mark.asyncio
async def test_receive_one_message(inbox):
    result = await inbox.receive()
    assert result["id"] == 11
    inbox.receive.assert_awaited_once_with()

Return a value from fixtures that need no cleanup. Use yield for async resources, and configure test and fixture loop scopes when connections are tied to an event loop. These settings belong to the async plugin.

PythonAsync resource with explicit cleanup
@pytest_asyncio.fixture
async def connection():
    conn = await open_connection()
    try:
        yield conn
    finally:
        await conn.close()
TOMLOptional pytest-asyncio configuration
[tool.pytest.ini_options]
asyncio_mode = "strict"
asyncio_default_test_loop_scope = "function"
asyncio_default_fixture_loop_scope = "function"
Choose the correct async backend
For Trio or AnyIO applications, use their appropriate pytest integration (pytest-trio or AnyIO's plugin). Mixing loop policies and sharing resources between event loops causes confusing failures.
15 / 26

Use popular plugins#

Plugins add fixtures, commands and hooks; check each plugin's docs before using its options.

pytest auto-loads installed plugin entry points by default. pytest --trace-config shows the loaded plugins. Disable one with -p no:plugin_name, or set PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 when debugging interference. The pip package name and pytest plugin name can differ.

ShellTypical optional integrations
python -m pip install pytest-mock pytest-cov pytest-xdist pytest-asyncio
python -m pytest --trace-config
python -m pytest -p no:cacheprovider

pytest-mock supplies mocker; pytest-cov supplies --cov; pytest-xdist supplies -n. These are separate packages, so install them before using their features. pytest-django offers Django settings, client and DB markers; pytest-httpx can fake HTTPX calls. Each has its own behavior and version requirements.

Pythonpytest-mock fixture (third-party)
def test_charge_call(mocker):
    charge = mocker.patch("app.billing.gateway.charge", autospec=True)
    charge.return_value = "receipt-9"
    assert billing.checkout({"id": 9, "total_cents": 80})["receipt"] == "receipt-9"
    charge.assert_called_once_with(80)
ShellCoverage and parallel workers (third-party)
python -m pytest --cov=app --cov-report=term-missing
python -m pytest -n auto
python -m pytest --cov=app --cov-branch --cov-report=html

xdist workers are separate processes: each gets its own process memory and fixture instances. Make test databases, ports, and temporary resources worker-safe. Coverage measures executed lines or branches, not the quality of assertions.

16 / 26

HTTP and service boundaries#

Use a framework test client for routes, replace external calls at the boundary, and assert both response and side effect.

Client fixtures are supplied by the web framework or by your project. The example below uses Flask's test client; pytest core does not provide client. For FastAPI, Django or other frameworks, use their documented test clients and DB integration.

PythonFlask endpoint test
# conftest.py
import pytest
from app.web import create_app


@pytest.fixture
def client():
    app = create_app(testing=True)
    return app.test_client()

# test_api.py
def test_create_order_requires_email(client):
    response = client.post("/orders", json={"items": ["A-1"]})
    assert response.status_code == 400
    assert response.json["code"] == "email_required"

A broader integration test can verify the real database transaction. Reset or roll back state after each test and create only the rows the test needs. Avoid a shared external service in unit tests. Test one known failure response as well as the happy path.

PythonDatabase transaction isolation, conceptual fixture
@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

The transaction pattern depends on your ORM and app wiring; nested transactions may be needed when the code under test commits. Prefer the framework's integration plugin when it already handles transactional tests.

17 / 26

Mocking boundaries safely#

A useful fake matches the relevant contract; a precise spy verifies an important interaction.

Prefer autospec=True for patched callables so wrong argument names fail early. Mock(spec=Type) restricts exposed attributes; spec_set also blocks assigning unknown attributes. Use side_effect to raise or return a sequence. An async function should be mocked with AsyncMock.

PythonFailure, sequence and async assertions
from unittest.mock import Mock, AsyncMock


def test_retry_after_timeout(mocker):
    send = mocker.patch("app.worker.gateway.send", autospec=True)
    send.side_effect = [TimeoutError("late"), {"id": "ok"}]
    result = worker.dispatch_with_retry({"id": 5})
    assert result == {"id": "ok"}
    assert send.call_count == 2


@pytest.mark.asyncio
async def test_async_dispatch(monkeypatch):
    send = AsyncMock(return_value={"id": "ok"})
    monkeypatch.setattr(async_worker.gateway, "send", send)
    assert await async_worker.dispatch({"id": 5}) == {"id": "ok"}
    send.assert_awaited_once_with({"id": 5})

mocker comes from pytest-mock; use unittest.mock.patch without that plugin. Patch where the code looks up a name. Keep a real integration test for behavior that mocks cannot verify.