Skip to content

Commit a6ae5aa

Browse files
committed
feat: add fastsqla-testing agent skill
1 parent 6e98cf4 commit a6ae5aa

File tree

1 file changed

+380
-0
lines changed

1 file changed

+380
-0
lines changed

skills/fastsqla-testing/SKILL.md

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
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

Comments
 (0)