You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When testing coroutine utilities (e.g., async validators or repository calls), keep the Arrange/Act/Assert flow inside the async context to avoid event-loop clashes. Newer pytest releases can auto-detect async tests, but explicit @pytest.mark.asyncio communicates intent.
FastAPI Testing
Choose the correct client based on how the endpoint is implemented:
TestClient (fastapi.testclient.TestClient): Ideal for sync-style request handlers. Provides a requests-like interface and runs inside a regular test function.
AsyncClient (httpx.AsyncClient): Required for fully async endpoints so you can await.get()/.post(). Supports WebSocket testing if needed.
The project provides shared fixtures in tests/conftest.py that implement this Composition Root approach, bypassing app/main.py and instantiating FastAPI via create_app in app/core/factory.py. Tests automatically pick up test_settings, test_app, client, and async_client without explicit imports, so prefer those fixtures before creating copies in individual modules.
Mocking patterns:
Override dependencies exposed via get_settings in app/core/dependencies.py to supply in-memory settings: app.dependency_overrides[get_settings] = lambda: Settings([ELIDED]).
Use unittest.mock or pytest monkeypatching to intercept future MCP server calls or outbound HTTP requests so component tests remain deterministic.
Note: The shared test_settings fixture already passes allow_missing_config=True, so only override get_settings when you need to test specific configuration permutations.
With these fixtures in place you can assert the JSON payload of /api/metadata/v1/health or verify that /api/metadata/v1/fetch returns serialized MetadataItem records that honor the contract defined in app/contracts/metadata_contract.py.
Store shared coverage defaults in pyproject.toml under [tool.coverage.run] and [tool.coverage.report] (e.g., omit = ["tests/*"], fail_under = 80). Failing the CI build when coverage dips below an agreed threshold keeps regressions visible.
Additional Best Practices
Reusable fixtures: For Settings tests, spin up temporary TOML files with tmp_path and point Settings to them, avoiding duplication. For metadata contracts, prebuild sample payload dictionaries reused across tests.
Lint before tests: Run uv run ruff check . ahead of pytest to catch import or style regressions before they fail assertions; see README.md for the full lint/format workflow, and rely on .pre-commit-config.yaml if you prefer automated checks on every commit.
Parametrization: Apply @pytest.mark.parametrize to cover multiple entity types in FetchRequest or edge cases for MetadataItem field validation without writing separate test functions.
Mirrored structure: Keep helper modules in tests/conftest.py or nested conftest.py files that shadow the runtime package layout, making it obvious where to extend fixture logic as the service grows.
Invest in these patterns now so future MCP skills can land with tests that demonstrate expected behavior across async workflows and FastAPI interactions.
Note: For async tests and FastAPI integration (e.g., using httpx.AsyncClient or fastapi.testclient.TestClient), follow the FastAPI testing patterns and fixtures defined in this repo (e.g., shared test_app or client fixtures, event loop fixtures, etc.).
Writing Tests
AAA + Traversal Rules
Adopt Arrange–Act–Assert with soft-style assertions and extracted traversals/conditions:
Arrange, Act, Assert in order; keep test bodies linear and readable.
Extract traversal and conditions into helpers (generators or pure functions).
Use “soft-style” assertions for multiple checks by collecting failures and asserting once at the end of the test; keep all assertion logic in the test body.
Avoid if statements in the test body; encode branching inside traversal helpers.
Use loops in the test only to iterate over traversal outputs (no ad-hoc iteration over raw nested structures).
Prefer parameterized tests (@pytest.mark.parametrize) to cover scenarios.
Group initialization/related tests with nested classes or modules; use fixtures (@pytest.fixture) and autouse fixtures instead of per-test setup where possible.
Example — traversal + soft-style assertions (Python/pytest-idiomatic):
Assertions remain in the test; traversal helpers only expose structure (array_stream, value_stream).
“Soft-style” behavior is implemented by collecting all failures into errors and asserting once; this surfaces all violations in one test run instead of failing fast on the first mismatch.
Use @pytest.mark.parametrize when each tuple should produce a separate test; use shared fixtures when multiple tests need the same setup or FastAPI app/client.
Prefer generator functions (def [ELIDED] -> Generator[ELIDED]) for traversals over building large intermediate lists to keep memory usage low and intent clear.
For FastAPI routes, follow the same AAA and traversal principles when asserting on JSON responses, headers, and status codes (e.g., traverse response payloads via helpers instead of inline nested loops/ifs in the test body).
Soft vs. Hard Assertions (pytest)
Prefer “soft-style” assertions (aggregate failures) for pytest tests in this project.
Soft-style assertions collect all failures and report them together at the end of the test, improving triage.
Hard assertions (single assert / pytest.fail) are allowed only when an immediate fail-fast is essential (e.g., validating a precondition before an expensive or destructive step).
Recommended pattern for soft-style assertions: collect error messages in a list and assert once at the end.
Example — allowed but use sparingly (hard fail-fast):
deftest_user_model_precondition() ->None:
user=get_user_from_db()
# Fail-fast if user is missing entirely; following checks depend on this.assertuserisnotNone# The rest can use soft-style if multiple conditions are checked.
Static analysis / lint rule nuance:
Some linters (e.g., ruff, flake8 plugins, Sonar) may expect at least one direct assert or pytest assertion per test and may not “see” your soft-style pattern if you hide everything behind helpers.
If a false positive appears for a test that clearly uses the soft-style pattern correctly, add a targeted disable comment at the top of the file or near the test:
# noqa: S101 (or the specific rule, e.g. sonar rule ID / ruff code)
Use this sparingly and only when the soft-style aggregation is used correctly and intentionally.
Async Test Patterns (FastAPI + pytest + httpx/clients)
For async tests (FastAPI endpoints, async services, async DB calls), distinguish between:
Async operations that produce values (e.g., await client.get([ELIDED]), await service.do_work()).
Plain value assertions (synchronous assert on the result).
@pytest.mark.asyncioasyncdeftest_service_async_call(service: "MyService") ->None:
# Await the producer, not the assertionresult=awaitservice.compute()
errors: list[str] = []
ifresult.total<=0:
errors.append(f"expected positive total, got {result.total}")
if"summary"notinresult.metadata:
errors.append("missing 'summary' in metadata")
assertnoterrors, ";\n".join(errors)
Avoid these patterns:
@pytest.mark.asyncioasyncdeftest_bad_unawaited_call(async_client: AsyncClient) ->None:
# ❌ Forgetting to await async operation: response is a coroutine, not a Responseresponse=async_client.get("/health") # missing await# assert response.status_code == 200 # will fail in confusing ways
@pytest.mark.asyncioasyncdeftest_hiding_coroutines(async_client: AsyncClient) ->None:
# ❌ Passing coroutines into helpers without awaiting inside the helperdefcheck_response(resp) ->list[str]:
# resp is a coroutine here, not the actual Responseerrors: list[str] = []
# Any attribute access will be wrongreturnerrorsresponse=async_client.get("/health") # missing awaiterrors=check_response(response)
assertnoterrors
Rule of thumb:
If the subject is an async operation (HTTP call, DB call, background task, etc.), always await it before asserting:
response = await async_client.get("/path")
result = await service.compute()
Assertions themselves are synchronous: assert on values, not on coroutines:
assert response.status_code == 200
Use the soft-style error aggregation pattern when you have multiple conditions.
Common pitfalls that lead to “hanging” or flaky async tests:
Forgetting to await async calls (e.g., client.get([ELIDED]) without await).
Spawning background tasks (asyncio.create_task, FastAPI background tasks, websockets) that keep running after the test ends.
Long/never-resolving waits (await asyncio.sleep with large values, await queue.get() without a producer).
Leaving open resources (unclosed AsyncClient, DB connections, server processes) when not using properly scoped fixtures.
Quick debugging tips:
Temporarily reduce timeouts in your app or client configuration for tests (e.g., HTTP client timeout).
Add explicit timeouts for awaits that depend on external systems or background work.
Search for:
Unawaited coroutines (often visible as warnings in test output).
Long sleeps / waits and queues without producers.
Use pytest’s verbose mode (-vv) and, if available, logging in your FastAPI app to see which request/operation was last started before the test stalled.
Integration Tests
Integration tests validate behavior across multiple layers of the application: FastAPI routes, service layer, persistence/DB, background tasks, and infrastructure boundaries.
Categories:
API-level tests: FastAPI route handlers + service layer + DB + dependencies.
Service-level integration tests: service logic + DB or external components (e.g., cache, message queue).
Middleware integration tests: custom middleware for auth, rate limiting, request shaping, ID injection, locale inference, feature flags, etc.
# Unit tests (co-located or under tests/unit/)
pytest tests/unit
# Integration tests
pytest tests/integration
# All tests
pytest
Watch mode is not built into pytest, but you can use ptw (pytest-watch) or pytest-testmon if you want that behavior.
With coverage (pytest-cov)
# Unit test coverage
pytest tests/unit --cov=your_app --cov-report=term-missing
# Integration test coverage
pytest tests/integration --cov=your_app --cov-report=term-missing
# All tests with coverage
pytest --cov=your_app --cov-report=term-missing
Run a specific test file
pytest tests/unit/test_user_service.py
Run a specific test or class
# Single test function
pytest tests/unit/test_user_service.py::test_creates_user_with_valid_data
# Single test class
pytest tests/unit/test_user_service.py::TestCreateUser
Run tests matching a pattern (similar to --grep)
# Match by test name substring / expression
pytest -k "UserService"
pytest -k "create_user and error"
Run tests for a specific package / submodule
# If tests are organized by package
pytest tests/unit/your_app/shared_types
# Or by file pattern
pytest tests -k "shared_types"
E2E tests
If you keep E2E tests under tests/e2e/ (e.g. using Playwright for Python or another E2E tool):
pytest tests/e2e
Or use the specific runner for your E2E framework if it is not pytest-based; just mirror the structure:
E2E tests live at tests/e2e/
E2E config in the root (e.g. playwright.config.py or equivalent)
# Generate coverage report (all tests)
coverage run -m pytest
# HTML coverage report
coverage html
# Open HTML coverage report (macOS)
open htmlcov/index.html
# Linux (example)
xdg-open htmlcov/index.html
# Windows (PowerShell)
start htmlcov\index.html
LLM coverage input: JSON per package/module
coverage.py can emit JSON directly:
# For a web app package
coverage run -m pytest apps/web
coverage json -o apps/web/coverage/coverage-final.json
# For shared types package
coverage run -m pytest packages/shared_types
coverage json -o packages/shared_types/coverage/coverage-final.json
# For query package
coverage run -m pytest packages/query
coverage json -o packages/query/coverage/coverage-final.json
Unit tests: aim for ≥ 80% (branches, functions, lines, statements)
Integration tests: aim for ≥ 70%
Overall target: 80%
Prioritize critical business logic; don’t chase 100% if it adds little value.
Even “types-first” or “schema-first” packages should use the same thresholds if they have runtime constructs (enums, helpers, validators) that execute at runtime and thus show up in coverage.
This gives you Python-native equivalents of your Vitest/bun workflow: coverage reports, JSON for tooling/LLMs, clear coverage goals, and structured mocking patterns for functions, modules, and time.
Uses pytest plus pytest-asyncio for @pytest.mark.asyncio.
fetch_user_async mirrors the Promise-based version.
The callback test uses an asyncio.Future to emulate Jest’s done callback.
You’re right that “E2E” is usually used for UI flows, but for an API-centric service, “E2E” is often just “hit the real HTTP API in something close to a production environment (docker-compose, real DB, etc.).” UI is optional; the key is that you’re exercising the full stack across a network boundary.
Here’s how I’d adapt those Playwright/hydration ideas to FastAPI + Uvicorn + pytest for API/E2E tests.
Terminology: what’s what
For a FastAPI service, you’ll typically see:
Unit tests
Call pure Python functions, maybe override dependencies. No HTTP.
Integration tests
Use FastAPI’s TestClient or httpx.AsyncClient against the app object. May use real DB/test DB, but often still in-process (no Uvicorn, no docker).
API E2E tests
App runs as a real process (Uvicorn or gunicorn) – often via docker-compose.
Tests talk to it via HTTP (e.g., http://api:8000) using httpx/requests.
Real-ish backing services (DB, cache, broker) are present.
So yes, “E2E via API only” is a thing and very common in backend-heavy systems.
Core idea translated from Playwright: avoid sleeps, use explicit readiness markers
In UI tests you “wait for hydration marker, then assert”.
In API tests you “wait for readiness/health marker, then assert.”
1. Prefer a health/readiness endpoint over time.sleep
This is the API analogue of “web-first assertions” instead of waitForTimeout.
2. pytest fixture for “app is ready”
If your app is started by docker-compose, you usually just need the base URL and a “ready” check:
# tests/conftest.pyimportosimportpytestfrom .utilsimportwait_for_service@pytest.fixture(scope="session")defapi_base_url() ->str:
# e.g. "http://localhost:8000" or docker-compose service hostreturnos.getenv("API_BASE_URL", "http://localhost:8000")
@pytest.fixture(scope="session", autouse=True)defwait_for_api(api_base_url: str):
wait_for_service(api_base_url)
yield# tests run after this point
Now all tests can safely call the API without random sleeps.
Example “E2E API” test with eventual consistency
Say you have an endpoint that kicks off some async work (e.g., background task) and later makes results available:
# app/main.py (simplified)fromfastapiimportFastAPIfrompydanticimportBaseModelapp=FastAPI()
_jobs= {}
classJobRequest(BaseModel):
payload: str@app.post("/jobs")defcreate_job(req: JobRequest):
job_id="some-id"# generated in real code_jobs[job_id] = {"status": "pending", "result": None}
# enqueue background work here[ELIDED]return {"job_id": job_id}
@app.get("/jobs/{job_id}")defget_job(job_id: str):
return_jobs[job_id]
API-level E2E test that avoids sleep by polling with a bounded timeout:
# tests/e2e/test_jobs.pyimporttimeimporthttpxdefwait_for_job_completion(base_url: str, job_id: str, timeout: float=30.0, interval: float=0.5):
deadline=time.time() +timeoutwhiletime.time() <deadline:
resp=httpx.get(f"{base_url}/jobs/{job_id}", timeout=5.0)
resp.raise_for_status()
body=resp.json()
ifbody.get("status") =="completed":
returnbodytime.sleep(interval)
raiseTimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
deftest_job_flow_end_to_end(api_base_url: str):
# Create jobcreate_resp=httpx.post(
f"{api_base_url}/jobs",
json={"payload": "test-data"},
timeout=5.0,
)
create_resp.raise_for_status()
job_id=create_resp.json()["job_id"]
# Wait for the asynchronous processing to finishjob_body=wait_for_job_completion(api_base_url, job_id)
# Assert on final resultassertjob_body["status"] =="completed"assertjob_body["result"] =="expected-result"
This mirrors the “wait for hydration marker, then assert on dynamic elements” idea, but for background work / eventual consistency at the API layer.
deftest_email_validation():
# format, length, domain, blacklist, etc. all in oneassertis_valid_email("john@example.com") isTrue# several unrelated concerns mixed together
Each test should have a clear, narrow responsibility.
Assuming tests live under tests/ with tests/unit, tests/component, tests/integration:
# Run tests matching a pattern in name or -k expression
pytest -k "should_process_data"# Run a specific test file
pytest tests/unit/test_user_service.py
# Run a specific test function in a file
pytest tests/unit/test_user_service.py::test_should_process_data
# Run a specific class method (if you use test classes)
pytest tests/unit/test_user_service.py::TestUserService::test_should_process_data