|
| 1 | +--- |
| 2 | +name: fastsqla-testing |
| 3 | +description: > |
| 4 | + Patterns for testing FastSQLA applications with pytest. Covers the async fixture |
| 5 | + chain (tmp SQLite → patched env → engine → table setup → FastAPI app → ASGI client), |
| 6 | + critical teardown to prevent mapper leaks between tests, direct DB verification with |
| 7 | + a separate session, and SQLModel integration test marks. |
| 8 | +--- |
| 9 | + |
| 10 | +# Testing FastSQLA Applications |
| 11 | + |
| 12 | +FastSQLA tests run as async integration tests against a per-test temporary SQLite database. The fixture chain is **order-sensitive** — each fixture depends on the previous one. |
| 13 | + |
| 14 | +## Test Dependencies |
| 15 | + |
| 16 | +Install the testing dependencies: |
| 17 | + |
| 18 | +``` |
| 19 | +pip install pytest pytest-asyncio httpx asgi-lifespan aiosqlite faker |
| 20 | +``` |
| 21 | + |
| 22 | +- **pytest-asyncio**: async test and fixture support |
| 23 | +- **httpx** + **asgi-lifespan**: ASGI client without a running server |
| 24 | +- **aiosqlite**: async SQLite driver for isolated per-test databases |
| 25 | +- **faker**: generates realistic test data (provides a `faker` pytest fixture) |
| 26 | + |
| 27 | +## pytest Configuration |
| 28 | + |
| 29 | +```toml |
| 30 | +[tool.pytest.ini_options] |
| 31 | +asyncio_mode = "auto" |
| 32 | +asyncio_default_fixture_loop_scope = "function" |
| 33 | +``` |
| 34 | + |
| 35 | +- `asyncio_mode = "auto"` — all `async def` tests and fixtures run without `@pytest.mark.asyncio` |
| 36 | +- `asyncio_default_fixture_loop_scope = "function"` — each test gets its own event loop |
| 37 | + |
| 38 | +## Fixture Chain |
| 39 | + |
| 40 | +The fixtures form a strict dependency chain. **Ordering matters** — creating tables must happen before `Base.prepare()` runs during the app lifespan, and mapper cleanup must run after every test. |
| 41 | + |
| 42 | +``` |
| 43 | +sqlalchemy_url → environ → engine → setup_tear_down → app → client |
| 44 | + ↘ session (direct DB verification) |
| 45 | +``` |
| 46 | + |
| 47 | +### 1. `sqlalchemy_url` — Per-test temporary database |
| 48 | + |
| 49 | +Each test gets its own SQLite file in pytest's `tmp_path`: |
| 50 | + |
| 51 | +```python |
| 52 | +from pytest import fixture |
| 53 | + |
| 54 | + |
| 55 | +@fixture |
| 56 | +def sqlalchemy_url(tmp_path): |
| 57 | + return f"sqlite+aiosqlite:///{tmp_path}/test.db" |
| 58 | +``` |
| 59 | + |
| 60 | +### 2. `environ` — Patched environment with `clear=True` |
| 61 | + |
| 62 | +Patches `os.environ` so FastSQLA's lifespan reads the test database URL. **`clear=True` is critical** — it prevents any stray `SQLALCHEMY_*` variables from the host environment from interfering: |
| 63 | + |
| 64 | +```python |
| 65 | +from unittest.mock import patch |
| 66 | +from pytest import fixture |
| 67 | + |
| 68 | + |
| 69 | +@fixture |
| 70 | +def environ(sqlalchemy_url): |
| 71 | + values = {"PYTHONASYNCIODEBUG": "1", "SQLALCHEMY_URL": sqlalchemy_url} |
| 72 | + with patch.dict("os.environ", values=values, clear=True): |
| 73 | + yield values |
| 74 | +``` |
| 75 | + |
| 76 | +### 3. `engine` — Async engine with teardown |
| 77 | + |
| 78 | +Creates a standalone engine for direct DB operations (table setup, data seeding, verification). Disposed after the test: |
| 79 | + |
| 80 | +```python |
| 81 | +from pytest import fixture |
| 82 | +from sqlalchemy.ext.asyncio import create_async_engine |
| 83 | + |
| 84 | + |
| 85 | +@fixture |
| 86 | +async def engine(environ): |
| 87 | + engine = create_async_engine(environ["SQLALCHEMY_URL"]) |
| 88 | + yield engine |
| 89 | + await engine.dispose() |
| 90 | +``` |
| 91 | + |
| 92 | +### 4. `setup_tear_down` — Create tables via raw SQL |
| 93 | + |
| 94 | +Tables **must** be created with raw SQL before `Base.prepare()` runs (which happens during the ASGI lifespan). This is because `Base` inherits from `DeferredReflection` — it reflects existing tables rather than creating them: |
| 95 | + |
| 96 | +```python |
| 97 | +from pytest import fixture |
| 98 | +from sqlalchemy import text |
| 99 | + |
| 100 | + |
| 101 | +@fixture(autouse=True) |
| 102 | +async def setup_tear_down(engine): |
| 103 | + async with engine.connect() as conn: |
| 104 | + await conn.execute( |
| 105 | + text(""" |
| 106 | + CREATE TABLE hero ( |
| 107 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 108 | + name TEXT UNIQUE NOT NULL, |
| 109 | + secret_identity TEXT NOT NULL, |
| 110 | + age INTEGER NOT NULL |
| 111 | + ) |
| 112 | + """) |
| 113 | + ) |
| 114 | +``` |
| 115 | + |
| 116 | +For tests that need seed data (e.g., pagination), insert rows in the same fixture using Core operations: |
| 117 | + |
| 118 | +```python |
| 119 | +from sqlalchemy import MetaData, Table |
| 120 | + |
| 121 | + |
| 122 | +@fixture(autouse=True) |
| 123 | +async def setup_tear_down(engine, faker): |
| 124 | + async with engine.connect() as conn: |
| 125 | + await conn.execute(text("CREATE TABLE user (...)")) |
| 126 | + metadata = MetaData() |
| 127 | + user = await conn.run_sync( |
| 128 | + lambda sync_conn: Table("user", metadata, autoload_with=sync_conn) |
| 129 | + ) |
| 130 | + await conn.execute( |
| 131 | + user.insert(), |
| 132 | + [{"email": faker.email(), "name": faker.name()} for _ in range(42)], |
| 133 | + ) |
| 134 | + await conn.commit() |
| 135 | +``` |
| 136 | + |
| 137 | +### 5. `app` — FastAPI application with models and routes |
| 138 | + |
| 139 | +The base `app` fixture creates a FastAPI app with the FastSQLA lifespan. Each test module **overrides** this fixture to register its own ORM models and routes: |
| 140 | + |
| 141 | +**Base fixture** (in `tests/integration/conftest.py`): |
| 142 | + |
| 143 | +```python |
| 144 | +from pytest import fixture |
| 145 | +from fastapi import FastAPI |
| 146 | + |
| 147 | + |
| 148 | +@fixture |
| 149 | +def app(environ): |
| 150 | + from fastsqla import lifespan |
| 151 | + app = FastAPI(lifespan=lifespan) |
| 152 | + return app |
| 153 | +``` |
| 154 | + |
| 155 | +**Per-module override** (depends on `setup_tear_down` to ensure tables exist first): |
| 156 | + |
| 157 | +```python |
| 158 | +from pytest import fixture |
| 159 | +from sqlalchemy.orm import Mapped, mapped_column |
| 160 | + |
| 161 | + |
| 162 | +@fixture |
| 163 | +def app(setup_tear_down, app): |
| 164 | + from fastsqla import Base, Item, Session |
| 165 | + |
| 166 | + class User(Base): |
| 167 | + __tablename__ = "user" |
| 168 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 169 | + email: Mapped[str] = mapped_column(unique=True) |
| 170 | + name: Mapped[str] |
| 171 | + |
| 172 | + @app.post("/users", response_model=Item[UserModel]) |
| 173 | + async def create_user(user_in: UserIn, session: Session): |
| 174 | + user = User(**user_in.model_dump()) |
| 175 | + session.add(user) |
| 176 | + await session.flush() |
| 177 | + return {"data": user} |
| 178 | + |
| 179 | + return app |
| 180 | +``` |
| 181 | + |
| 182 | +**Key detail**: The overriding `app` fixture takes `setup_tear_down` as a parameter to enforce that tables are created before `Base.prepare()` runs. |
| 183 | + |
| 184 | +### 6. `client` — ASGI test client |
| 185 | + |
| 186 | +Uses `asgi-lifespan` to trigger the app's startup/shutdown events (which runs `Base.prepare()`), then wraps it with httpx for HTTP requests: |
| 187 | + |
| 188 | +```python |
| 189 | +from asgi_lifespan import LifespanManager |
| 190 | +from httpx import AsyncClient, ASGITransport |
| 191 | +from pytest import fixture |
| 192 | + |
| 193 | + |
| 194 | +@fixture |
| 195 | +async def client(app): |
| 196 | + async with LifespanManager(app) as manager: |
| 197 | + transport = ASGITransport(app=manager.app) |
| 198 | + async with AsyncClient(transport=transport, base_url="http://app") as client: |
| 199 | + yield client |
| 200 | +``` |
| 201 | + |
| 202 | +## Critical Teardown — Preventing Mapper Leaks |
| 203 | + |
| 204 | +**This is the most common testing pitfall.** Without this autouse fixture, ORM model definitions from one test leak into subsequent tests, causing `ArgumentError: Class already has a primary mapper defined` or `InvalidRequestError`: |
| 205 | + |
| 206 | +```python |
| 207 | +from pytest import fixture |
| 208 | + |
| 209 | + |
| 210 | +@fixture(autouse=True) |
| 211 | +def tear_down(): |
| 212 | + from sqlalchemy.orm import clear_mappers |
| 213 | + from fastsqla import Base |
| 214 | + |
| 215 | + yield |
| 216 | + |
| 217 | + Base.metadata.clear() |
| 218 | + clear_mappers() |
| 219 | +``` |
| 220 | + |
| 221 | +**Why both calls?** |
| 222 | +- `Base.metadata.clear()` removes all table definitions from the shared `MetaData` |
| 223 | +- `clear_mappers()` removes all class-to-table mapper configurations |
| 224 | + |
| 225 | +This fixture is **synchronous** and **autouse** — it runs automatically after every test without being explicitly requested. |
| 226 | + |
| 227 | +## Direct Data Verification |
| 228 | + |
| 229 | +Use a separate `session` fixture bound to the same engine to query the database directly, bypassing the application layer. This verifies that the app actually persisted data: |
| 230 | + |
| 231 | +```python |
| 232 | +from pytest import fixture |
| 233 | +from sqlalchemy.ext.asyncio import AsyncSession |
| 234 | + |
| 235 | + |
| 236 | +@fixture |
| 237 | +async def session(engine): |
| 238 | + async with engine.connect() as conn: |
| 239 | + yield AsyncSession(bind=conn) |
| 240 | +``` |
| 241 | + |
| 242 | +Usage in a test: |
| 243 | + |
| 244 | +```python |
| 245 | +from sqlalchemy import text |
| 246 | + |
| 247 | + |
| 248 | +async def test_user_is_persisted(client, session): |
| 249 | + payload = {"email": "bob@bob.com", "name": "Bobby"} |
| 250 | + res = await client.post("/users", json=payload) |
| 251 | + assert res.status_code == 201 |
| 252 | + |
| 253 | + rows = (await session.execute(text("SELECT * FROM user"))).mappings().all() |
| 254 | + assert rows == [{"id": 1, **payload}] |
| 255 | +``` |
| 256 | + |
| 257 | +## SQLModel Integration Tests |
| 258 | + |
| 259 | +Tests requiring SQLModel use a custom marker and an autouse fixture that skips them when SQLModel is not installed: |
| 260 | + |
| 261 | +### Register the marker |
| 262 | + |
| 263 | +```python |
| 264 | +def pytest_configure(config): |
| 265 | + config.addinivalue_line( |
| 266 | + "markers", "require_sqlmodel: skip test when sqlmodel is not installed." |
| 267 | + ) |
| 268 | +``` |
| 269 | + |
| 270 | +### Auto-skip fixture |
| 271 | + |
| 272 | +```python |
| 273 | +from pytest import fixture, skip |
| 274 | + |
| 275 | +try: |
| 276 | + import sqlmodel |
| 277 | +except ImportError: |
| 278 | + is_sqlmodel_installed = False |
| 279 | +else: |
| 280 | + is_sqlmodel_installed = True |
| 281 | + |
| 282 | + |
| 283 | +@fixture(autouse=True) |
| 284 | +def check_sqlmodel(request): |
| 285 | + marker = request.node.get_closest_marker("require_sqlmodel") |
| 286 | + if marker and not is_sqlmodel_installed: |
| 287 | + skip(f"{request.node.nodeid} requires sqlmodel which is not installed.") |
| 288 | +``` |
| 289 | + |
| 290 | +### Mark an entire module |
| 291 | + |
| 292 | +```python |
| 293 | +from pytest import mark |
| 294 | + |
| 295 | +pytestmark = mark.require_sqlmodel |
| 296 | +``` |
| 297 | + |
| 298 | +### SQLModel model definition |
| 299 | + |
| 300 | +SQLModel models need `__table_args__ = {"extend_existing": True}` since the table was already created via raw SQL and reflected by `Base.prepare()`: |
| 301 | + |
| 302 | +```python |
| 303 | +from sqlmodel import Field, SQLModel |
| 304 | + |
| 305 | + |
| 306 | +class Hero(SQLModel, table=True): |
| 307 | + __table_args__ = {"extend_existing": True} |
| 308 | + id: int | None = Field(default=None, primary_key=True) |
| 309 | + name: str |
| 310 | + secret_identity: str |
| 311 | + age: int |
| 312 | +``` |
| 313 | + |
| 314 | +## Integration Test Example |
| 315 | + |
| 316 | +A complete test that creates a resource via POST and verifies it was persisted: |
| 317 | + |
| 318 | +```python |
| 319 | +from http import HTTPStatus |
| 320 | +from pydantic import BaseModel, ConfigDict |
| 321 | +from pytest import fixture |
| 322 | +from sqlalchemy import select, text |
| 323 | +from sqlalchemy.exc import IntegrityError |
| 324 | +from sqlalchemy.orm import Mapped, mapped_column |
| 325 | +from fastapi import HTTPException |
| 326 | + |
| 327 | + |
| 328 | +@fixture(autouse=True) |
| 329 | +async def setup_tear_down(engine): |
| 330 | + async with engine.connect() as conn: |
| 331 | + await conn.execute( |
| 332 | + text(""" |
| 333 | + CREATE TABLE user ( |
| 334 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 335 | + email TEXT UNIQUE NOT NULL, |
| 336 | + name TEXT NOT NULL |
| 337 | + ) |
| 338 | + """) |
| 339 | + ) |
| 340 | + |
| 341 | + |
| 342 | +@fixture |
| 343 | +def app(setup_tear_down, app): |
| 344 | + from fastsqla import Base, Item, Session |
| 345 | + |
| 346 | + class User(Base): |
| 347 | + __tablename__ = "user" |
| 348 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 349 | + email: Mapped[str] = mapped_column(unique=True) |
| 350 | + name: Mapped[str] |
| 351 | + |
| 352 | + class UserIn(BaseModel): |
| 353 | + email: str |
| 354 | + name: str |
| 355 | + |
| 356 | + class UserModel(UserIn): |
| 357 | + model_config = ConfigDict(from_attributes=True) |
| 358 | + id: int |
| 359 | + |
| 360 | + @app.post("/users", response_model=Item[UserModel], status_code=HTTPStatus.CREATED) |
| 361 | + async def create_user(user_in: UserIn, session: Session): |
| 362 | + user = User(**user_in.model_dump()) |
| 363 | + session.add(user) |
| 364 | + try: |
| 365 | + await session.flush() |
| 366 | + except IntegrityError: |
| 367 | + raise HTTPException(status_code=400) |
| 368 | + return {"data": user} |
| 369 | + |
| 370 | + return app |
| 371 | + |
| 372 | + |
| 373 | +async def test_create_and_verify(client, session): |
| 374 | + payload = {"email": "bob@bob.com", "name": "Bobby"} |
| 375 | + res = await client.post("/users", json=payload) |
| 376 | + assert res.status_code == HTTPStatus.CREATED |
| 377 | + |
| 378 | + all_users = (await session.execute(text("SELECT * FROM user"))).mappings().all() |
| 379 | + assert all_users == [{"id": 1, **payload}] |
| 380 | +``` |
0 commit comments