asyncio testing
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user