diff --git a/docs/skills/pytest-scaffolding/SKILL.md b/docs/skills/pytest-scaffolding/SKILL.md index 4bb5831..42069da 100644 --- a/docs/skills/pytest-scaffolding/SKILL.md +++ b/docs/skills/pytest-scaffolding/SKILL.md @@ -23,6 +23,7 @@ Use it to quickly find the right guidance for: 2. Naming conventions and test hierarchy organization. 3. FastAPI route, dependency override, and lifespan testing patterns. 4. SQLAlchemy transaction and session testing patterns. +5. AsyncIO loop-scope, fixture-lifecycle, and cancellation-safe testing patterns. Repository defaults: - `uv run pytest` is the canonical invocation. @@ -53,6 +54,7 @@ Open only the reference that matches the immediate task. 2. Fixture layering, marker policy, collect-only and fast-path commands: [pytest-docs.md](./references/pytest-docs.md) 3. Route tests, dependency overrides, lifespan handling: [fastapi-testing.md](./references/fastapi-testing.md) 4. Session and transaction fixtures, async ORM behavior: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md) +5. Async test mode selection, event loop scope, cancel-scope teardown issues: [asyncio-testing.md](./references/asyncio-testing.md) ## Naming Pull-In Triggers @@ -96,6 +98,7 @@ Apply this policy whenever a test change introduces a fake collaborator or patch - For FastAPI, prefer dependency overrides and clear lifecycle handling; see [fastapi-testing.md](./references/fastapi-testing.md). - For SQLAlchemy, prefer transaction-safe session fixtures and explicit async loading strategy; see [sqlalchemy-testing.md](./references/sqlalchemy-testing.md). +- For async fixtures, loop-scope selection, and cancellation-safe teardown, see [asyncio-testing.md](./references/asyncio-testing.md). - For naming and tree organization, use the conventions in [naming-and-organization.md](./references/naming-and-organization.md). ## Source Documentation Entry Points @@ -108,6 +111,8 @@ Primary upstream docs are curated in each reference page. Start with: 4. FastAPI testing: [FastAPI testing tutorial](https://fastapi.tiangolo.com/tutorial/testing/) 5. SQLAlchemy transaction testing: [SQLAlchemy external transaction pattern](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites) 6. Pytest monkeypatch usage and limits: [monkeypatch how-to](https://docs.pytest.org/en/stable/how-to/monkeypatch.html) +7. pytest-asyncio configuration: [pytest-asyncio config](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html) +8. AnyIO cancellation semantics: [AnyIO cancellation and timeouts](https://anyio.readthedocs.io/en/stable/cancellation.html) ## Quick Validation Commands diff --git a/docs/skills/pytest-scaffolding/references/asyncio-testing.md b/docs/skills/pytest-scaffolding/references/asyncio-testing.md new file mode 100644 index 0000000..6afa85a --- /dev/null +++ b/docs/skills/pytest-scaffolding/references/asyncio-testing.md @@ -0,0 +1,108 @@ +# AsyncIO Testing Patterns (Pytest, FastAPI, AnyIO) + +!!! info "Primary sources" + - [pytest-asyncio configuration](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html) + - [pytest-asyncio concepts](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html) + - [pytest-asyncio fixture loop scope how-to](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_fixture_loop.html) + - [pytest-asyncio default fixture loop scope how-to](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_default_fixture_loop.html) + - [AnyIO cancellation and cancel-scope safety](https://anyio.readthedocs.io/en/stable/cancellation.html) + - [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/) + +## Agent Quick Path +Use this reference when tests involve asynchronous fixtures, HTTP clients, task groups, or teardown failures. + +1. Confirm async plugin mode in pytest config (`asyncio_mode`). +2. Keep async fixture loop scope predictable, defaulting to `function` unless there is a measured need to broaden it. +3. Prefer one async testing model per lane (pytest-asyncio or AnyIO-style markers), and keep it consistent. +4. Keep async fixtures small and isolate stateful resources to the narrowest useful scope. +5. If teardown errors mention cancel scopes or task groups, validate that setup and teardown run in the same task context. + +## Baseline Configuration + +Recommended defaults for most projects using `pytest-asyncio`: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +``` + +Why: +- [Strict mode](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html#test-discovery-modes) is safer for multi-plugin environments, but [auto mode](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html#test-discovery-modes) is often simpler when the suite is primarily asyncio-based. +- [Function loop scope](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html#asyncio-default-fixture-loop-scope) minimizes cross-test coupling and avoids many lifecycle surprises. + +If a fixture or test needs broader loop sharing, make it explicit instead of changing suite-wide defaults: + +```python +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture(loop_scope="module") +async def shared_resource(): + ... + + +@pytest.mark.asyncio(loop_scope="module") +async def test_uses_shared_loop(shared_resource): + ... +``` + +## FastAPI Endpoint Test Patterns + +Use [FastAPI's async testing guidance](https://fastapi.tiangolo.com/advanced/async-tests/) as the default: + +1. Use `httpx.AsyncClient` with `ASGITransport` for async endpoint tests. +2. Mark async tests with one consistent marker style for the suite. +3. If app lifespan hooks matter, add [LifespanManager](https://fastapi.tiangolo.com/advanced/async-tests/#httpx) support because `AsyncClient` alone does not trigger lifespan events. + +Example: + +```python +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.mark.asyncio +async def test_healthz(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/healthz") + + assert response.status_code == 200 +``` + +## Fixture Design For Async Reliability + +Apply these patterns first: + +1. Keep async fixtures narrow (`function` scope by default). +2. Keep one responsibility per fixture when possible. +3. Prefer yield fixtures and pair each setup step with teardown in the same fixture. +4. Avoid mixing many independent event-loop lifecycles in one fixture chain. + +When using transports that manage internal task groups (for example, streaming clients), avoid patterns that risk splitting lifecycle across different task contexts. + +## Troubleshooting Cancel-Scope Teardown Failures + +When you see errors like `Attempted to exit cancel scope in a different task than it was entered in`, treat it as an async lifecycle-ownership issue first. + +Checklist: + +1. Verify fixture and test loop scopes are compatible and explicit. +2. Confirm async resource setup and teardown are owned by the same fixture context. +3. Reduce fixture scope (`module` or `session` -> `function`) to test for loop/task ownership drift. +4. Ensure the suite uses one primary async plugin model for the failing lane. +5. Re-run with focused selection and skip reasons to isolate first failing fixture: + - `uv run --group test python -m pytest -m smoke tests/web -q -rs` + +Relevant references: +- [Avoiding cancel scope stack corruption](https://anyio.readthedocs.io/en/stable/cancellation.html#avoiding-cancel-scope-stack-corruption) +- [pytest-asyncio configuration](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html) +- [pytest fixture teardown behavior](https://docs.pytest.org/en/stable/how-to/fixtures.html#teardown-cleanup-aka-fixture-finalization) + +## Commands Worth Remembering + +- `uv run --group test python -m pytest --collect-only -q` +- `uv run --group test python -m pytest -m smoke tests/web -q -rs` +- `uv run --group test python -m pytest -m integration -q` +- `uv run --group test python -m pytest -q` \ No newline at end of file