template updates

This commit is contained in:
John Lancaster
2026-08-06 23:17:50 -05:00
parent 0dc06f72ca
commit 7ac90d29dd
8 changed files with 352 additions and 295 deletions
@@ -14,7 +14,7 @@ SQLite is a useful fast target, not a drop-in PostgreSQL substitute. Keep a smal
## Shared Construction Primitives
Make the application factory accept a database URL or settings object. Production, workers, and ordinary integration tests enter the same [`database_scope()`](engine.md#one-engine-context-manager). Tests enter the lower-level `engine_scope()` only when they need direct engine or connection ownership for schema setup, an outer transaction, or engine-specific assertions:
Make the application factory accept a database URL or settings object. Production, workers, and ordinary integration tests enter the same [`database_scope()`](session.md#database-and-convenience-scopes). Tests enter the lower-level [`engine_scope()`](engine.md#owning-engine-scope) only when they need direct engine or connection ownership for schema setup, an outer transaction, or engine-specific assertions:
```python
from collections.abc import AsyncGenerator
@@ -25,13 +25,13 @@ from sqlalchemy.ext.asyncio import AsyncEngine
from .engine import engine_scope
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def test_engine(database_url: str) -> AsyncGenerator[AsyncEngine]:
async with engine_scope(database_url) as engine:
yield engine
```
Production passes its `postgresql+asyncpg://...` URL to `database_scope()`. A local SQLite run passes `sqlite+aiosqlite:///./app.db`. Tests pass a dedicated test URL to `database_scope()` or `engine_scope()` and receive deterministic disposal when the context exits. Do not create an engine during module import: that makes it easy for tests to retain the production URL before an override is applied.
Production passes its `postgresql+asyncpg://...` URL to `database_scope()`. A local SQLite run passes `sqlite+aiosqlite:///./app.db`. Tests pass a dedicated test URL to `database_scope()` or `engine_scope()` and receive schema initialization, deterministic disposal, and engine-cache cleanup when the context exits. Do not create an engine during module import: that makes it easy for tests to retain the production URL before an override is applied.
Use migrations to provision an integration database when migrations are part of the release contract. `metadata.create_all()` is appropriate for focused ORM tests only when it accurately represents the schema under test. Import all table models before creating metadata; [SQLModel documents that model-registration order matters](https://sqlmodel.tiangolo.com/tutorial/fastapi/tests/#import-table-models).
@@ -50,7 +50,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from .session import SessionFactory
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="function", loop_scope="session")
async def session_factory(test_engine: AsyncEngine) -> AsyncGenerator[SessionFactory]:
async with test_engine.connect() as connection:
transaction = await connection.begin()
@@ -68,19 +68,19 @@ async def session_factory(test_engine: AsyncEngine) -> AsyncGenerator[SessionFac
Each factory call still creates a distinct `AsyncSession`, matching [session factory mechanics](session.md#session-factory-mechanics). The factory belongs to the fixture's engine and outer transaction and must not escape either scope.
For service tests that need to pass a caller-owned active session into `transaction_scope(session=...)`, derive that session from the same factory:
For service tests that pass a caller-owned session into decorated or undecorated service functions, derive that session from the same factory:
```python
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="function", loop_scope="session")
async def session(session_factory: SessionFactory) -> AsyncGenerator[AsyncSession]:
async with session_factory() as test_session:
await test_session.begin()
yield test_session
```
The explicit `begin()` satisfies the supplied-session contract from [session management](session.md#one-optional-ownership-helper). Session closure rolls back unfinished work; the outer connection transaction remains the final isolation boundary even if application code commits its SAVEPOINT.
The explicit `begin()` gives test code one visible transaction from the start. Session closure rolls back unfinished work; the outer connection transaction remains the final isolation boundary even if application code commits its SAVEPOINT.
For FastAPI request tests, override `get_session_factory`, not only `get_session`. Both `SessionDep` and `TransactionSessionDep` then retain their production ownership behavior while receiving the test-bound factory. Always remove the override after the test because [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) are stored in a mutable application-level dictionary.
For FastAPI request tests, override `_get_session_factory` so the production `SessionDep` retains its session-creation and cleanup behavior while receiving the test-bound factory. Always remove the override after the test because [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) are stored in a mutable application-level dictionary.
```python
from collections.abc import Generator
@@ -88,7 +88,7 @@ from collections.abc import Generator
import pytest
from fastapi import FastAPI
from .fastapi import get_session_factory
from .fastapi import _get_session_factory
from .session import SessionFactory
@@ -97,11 +97,11 @@ def app_with_test_database(app: FastAPI, session_factory: SessionFactory) -> Gen
def get_test_session_factory() -> SessionFactory:
return session_factory
app.dependency_overrides[get_session_factory] = get_test_session_factory
app.dependency_overrides[_get_session_factory] = get_test_session_factory
try:
yield app
finally:
app.dependency_overrides.pop(get_session_factory, None)
app.dependency_overrides.pop(_get_session_factory, None)
```
Construct `app` with test settings before lifespan starts so startup cannot resolve the production URL. The override changes request session creation; it does not prevent lifespan from entering its configured `database_scope()`.
@@ -114,23 +114,19 @@ The connection-bound factory is deliberately serial even though it creates disti
Use `sqlite+aiosqlite://` for a fresh in-memory database when the test runs all database work serially. SQLAlchemy's `aiosqlite` dialect uses a single-connection `StaticPool` for this target, so all sessions share one SQLite transaction state. One session's rollback can discard another session's uncommitted work.
Create the schema and dispose the engine deterministically:
`engine_scope()` imports the model package and creates the schema by default, then disposes the engine and clears cached resolution deterministically:
```python
from collections.abc import AsyncGenerator
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import SQLModel
from .engine import engine_scope
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def test_engine() -> AsyncGenerator[AsyncEngine]:
async with engine_scope("sqlite+aiosqlite://") as engine:
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
yield engine
```
@@ -161,8 +157,8 @@ For both SQLite forms, enable and test the constraints your application depends
- A test run cannot reach the production URL; production credentials are absent from the test environment.
- Production PostgreSQL, local SQLite, and in-memory SQLite all use `database_scope()` unless a test explicitly needs lower-level engine or connection ownership.
- Every test owns its override, session factory, connection, transaction, session, and engine cleanup.
- Request tests override `get_session_factory`, preserving both read-session and transactional-session dependency behavior.
- Every test or fixture scope owns its override, session factory, connection, transaction, and session cleanup; the session-scoped engine fixture owns disposal and cache cleanup.
- Request tests override `_get_session_factory`, preserving production request-session creation and cleanup behavior.
- Test data is deterministic, minimal, and expresses the scenario under test.
- PostgreSQL integration tests cover every PostgreSQL-specific contract and run against migrations where migrations are shipped.