Skip to main content

Testing SQLModel Operations

API tests go through HTTP. Model tests go straight to the database.

You've written endpoint tests with httpx and the AsyncClient. Those tests verify that your API handles requests correctly, but they test the entire stack: routing, validation, serialization, and database operations. When a test fails, you don't immediately know which layer caused the problem.

Model tests isolate database operations. They work directly with SQLModel and your database session, skipping the HTTP layer entirely. This gives you faster tests and more precise failure messages. When a model test fails, you know the problem is in your data layer, not your API routing.

This lesson teaches you to set up an isolated test database and write tests for model operations: creating records, testing relationships, verifying cascade deletes, and catching constraint violations.


Why Model Tests Matter

Consider a failing API test:

@pytest.mark.asyncio
async def test_create_task(client: AsyncClient):
response = await client.post("/api/tasks", json={"title": "Test"})
assert response.status_code == 201 # FAILS: 422

The failure could come from:

  • Invalid request validation
  • Missing authentication
  • Database constraint violation
  • Model validation error
  • Serialization problem

A model test is more precise:

@pytest.mark.asyncio
async def test_create_task(session: AsyncSession):
task = Task(title="Test")
session.add(task)
await session.commit() # FAILS: IntegrityError

When this fails, you know the database layer has a problem. No HTTP, no routing, no serialization—just your model and the database.

Test TypeSpeedPrecisionUse When
API TestsSlowerLower (tests full stack)Testing HTTP behavior, auth, response format
Model TestsFasterHigher (tests one layer)Testing data integrity, relationships, constraints

In-Memory SQLite Setup

In-memory SQLite databases exist only in RAM. They're fast and automatically clean up. But they have a quirk: each new connection gets a fresh, empty database.

The Problem

Standard connection pooling creates new connections as needed. With in-memory SQLite, each connection sees its own empty database:

# This WON'T work reliably
engine = create_async_engine("sqlite+aiosqlite:///:memory:")

# Connection 1: Creates tables
# Connection 2: New connection = empty database!

Your tests create tables in one connection, then query from another connection that has no tables.

The Solution: StaticPool

StaticPool keeps a single connection open for all database operations:

from sqlalchemy.pool import StaticPool
from sqlalchemy.ext.asyncio import create_async_engine

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

test_engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)

Output:

# All operations use the same connection
# Tables created once, visible to all queries

The connect_args={"check_same_thread": False} setting allows SQLite to be used from multiple threads, which pytest needs for async test execution.


Database Fixture

Create tables before each test, drop them after:

# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

test_engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)

TestAsyncSession = async_sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)

@pytest.fixture(autouse=True)
async def setup_database():
"""Create tables before each test, drop after."""
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)

@pytest.fixture
async def session():
"""Provide async session for model tests."""
async with TestAsyncSession() as session:
yield session

Output:

# Each test starts with clean tables
# No data leaks between tests

The autouse=True parameter means this fixture runs for every test automatically. You don't need to add it as a parameter.


Basic Model Tests

Test your model operations directly:

# tests/test_models.py
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select
from app.models import Task

@pytest.mark.asyncio
async def test_create_task(session: AsyncSession):
"""Test Task model creation."""
task = Task(title="Test Task", priority="high")
session.add(task)
await session.commit()
await session.refresh(task)

assert task.id is not None
assert task.title == "Test Task"
assert task.priority == "high"
assert task.status == "pending" # default value

@pytest.mark.asyncio
async def test_task_defaults(session: AsyncSession):
"""Test Task default values are applied."""
task = Task(title="Minimal Task")
session.add(task)
await session.commit()
await session.refresh(task)

assert task.status == "pending"
assert task.priority == "medium"
assert task.created_at is not None

@pytest.mark.asyncio
async def test_query_tasks(session: AsyncSession):
"""Test querying tasks from database."""
# Create test data
task1 = Task(title="Task 1", status="pending")
task2 = Task(title="Task 2", status="completed")
session.add_all([task1, task2])
await session.commit()

# Query
statement = select(Task).where(Task.status == "pending")
result = await session.exec(statement)
pending_tasks = result.all()

assert len(pending_tasks) == 1
assert pending_tasks[0].title == "Task 1"

Output:

========================= test session starts ==========================
collected 3 items

tests/test_models.py::test_create_task PASSED [ 33%]
tests/test_models.py::test_task_defaults PASSED [ 66%]
tests/test_models.py::test_query_tasks PASSED [100%]

========================== 3 passed in 0.15s ===========================

Testing Relationships

Test that model relationships work correctly:

# tests/test_models.py (continued)
from app.models import Task, Project

@pytest.mark.asyncio
async def test_task_project_relationship(session: AsyncSession):
"""Test Task belongs to Project relationship."""
# Create project first
project = Project(name="Test Project")
session.add(project)
await session.commit()
await session.refresh(project)

# Create task with project
task = Task(title="Project Task", project_id=project.id)
session.add(task)
await session.commit()
await session.refresh(task)

# Verify relationship
assert task.project_id == project.id

# Load relationship
await session.refresh(task, ["project"])
assert task.project.name == "Test Project"

@pytest.mark.asyncio
async def test_project_tasks_relationship(session: AsyncSession):
"""Test Project has many Tasks relationship."""
project = Project(name="Multi-Task Project")
session.add(project)
await session.commit()

# Add multiple tasks
task1 = Task(title="Task 1", project_id=project.id)
task2 = Task(title="Task 2", project_id=project.id)
session.add_all([task1, task2])
await session.commit()

# Verify through relationship
await session.refresh(project, ["tasks"])
assert len(project.tasks) == 2
titles = {t.title for t in project.tasks}
assert titles == {"Task 1", "Task 2"}

Output:

tests/test_models.py::test_task_project_relationship PASSED
tests/test_models.py::test_project_tasks_relationship PASSED

Cascade Delete Testing

Test that deleting a parent record cascades to children:

# tests/test_models.py (continued)
@pytest.mark.asyncio
async def test_cascade_delete_project_removes_tasks(session: AsyncSession):
"""Test deleting Project cascades to its Tasks."""
# Setup: Project with tasks
project = Project(name="Doomed Project")
session.add(project)
await session.commit()
await session.refresh(project)

task1 = Task(title="Task 1", project_id=project.id)
task2 = Task(title="Task 2", project_id=project.id)
session.add_all([task1, task2])
await session.commit()

task1_id = task1.id
task2_id = task2.id

# Act: Delete project
await session.delete(project)
await session.commit()

# Assert: Tasks are gone
remaining_task1 = await session.get(Task, task1_id)
remaining_task2 = await session.get(Task, task2_id)

assert remaining_task1 is None
assert remaining_task2 is None

Output:

tests/test_models.py::test_cascade_delete_project_removes_tasks PASSED

This test verifies your foreign key relationship is configured with ON DELETE CASCADE. Without it, you'd get a foreign key constraint error or orphaned records.


Constraint Violation Testing

Test that database constraints are enforced:

# tests/test_models.py (continued)
from sqlalchemy.exc import IntegrityError

@pytest.mark.asyncio
async def test_unique_constraint_violation(session: AsyncSession):
"""Test unique constraint on (project_id, title) is enforced."""
project = Project(name="Constraint Test Project")
session.add(project)
await session.commit()

# First task succeeds
task1 = Task(title="Unique Title", project_id=project.id)
session.add(task1)
await session.commit()

# Duplicate title in same project should fail
task2 = Task(title="Unique Title", project_id=project.id)
session.add(task2)

with pytest.raises(IntegrityError):
await session.commit()

@pytest.mark.asyncio
async def test_required_field_violation(session: AsyncSession):
"""Test NOT NULL constraint on required fields."""
# Task without title should fail
task = Task(title=None) # type: ignore
session.add(task)

with pytest.raises(IntegrityError):
await session.commit()

@pytest.mark.asyncio
async def test_foreign_key_violation(session: AsyncSession):
"""Test foreign key constraint prevents invalid references."""
# Task with non-existent project
task = Task(title="Orphan Task", project_id=99999)
session.add(task)

with pytest.raises(IntegrityError):
await session.commit()

Output:

tests/test_models.py::test_unique_constraint_violation PASSED
tests/test_models.py::test_required_field_violation PASSED
tests/test_models.py::test_foreign_key_violation PASSED

Constraint tests catch problems early. If someone removes a database constraint, these tests fail immediately.


SQLite vs PostgreSQL Differences

SQLite and PostgreSQL handle some types differently. Be aware of these when your production database differs from your test database.

JSON Fields

PostgreSQL has JSONB (binary JSON). SQLite uses TEXT:

# Model definition works with both
from sqlmodel import Field
from typing import Optional
import json

class Task(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
metadata: str | None = None # Use str, not dict

def get_metadata(self) -> dict:
"""Parse metadata JSON."""
if self.metadata:
return json.loads(self.metadata)
return {}

def set_metadata(self, data: dict):
"""Store metadata as JSON string."""
self.metadata = json.dumps(data)

Testing JSON Fields

@pytest.mark.asyncio
async def test_json_metadata_storage(session: AsyncSession):
"""Test JSON metadata stored and retrieved correctly."""
task = Task(title="Task with Metadata")
task.set_metadata({"tags": ["urgent", "review"], "estimate_hours": 4})
session.add(task)
await session.commit()
await session.refresh(task)

metadata = task.get_metadata()
assert metadata["tags"] == ["urgent", "review"]
assert metadata["estimate_hours"] == 4

Output:

tests/test_models.py::test_json_metadata_storage PASSED

This approach works with both SQLite (tests) and PostgreSQL (production).


Hands-On Exercise

Create a complete model test file for your Task API.

Step 1: Set Up Test Models

Create tests/test_models.py:

# tests/test_models.py
import pytest
from sqlalchemy.exc import IntegrityError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from app.models import Task, Project

# Import TestAsyncSession from conftest if needed
from tests.conftest import TestAsyncSession


@pytest.fixture
async def session():
"""Provide async session for model tests."""
async with TestAsyncSession() as session:
yield session

Step 2: Write CRUD Tests

Add tests for create, read, update, delete:

@pytest.mark.asyncio
async def test_create_task(session: AsyncSession):
task = Task(title="New Task", priority="high")
session.add(task)
await session.commit()
await session.refresh(task)

assert task.id is not None
assert task.status == "pending"

@pytest.mark.asyncio
async def test_update_task(session: AsyncSession):
task = Task(title="Original Title")
session.add(task)
await session.commit()

task.title = "Updated Title"
task.status = "completed"
await session.commit()
await session.refresh(task)

assert task.title == "Updated Title"
assert task.status == "completed"

@pytest.mark.asyncio
async def test_delete_task(session: AsyncSession):
task = Task(title="To Delete")
session.add(task)
await session.commit()
task_id = task.id

await session.delete(task)
await session.commit()

result = await session.get(Task, task_id)
assert result is None

Step 3: Add Relationship Tests

@pytest.mark.asyncio
async def test_task_project_relationship(session: AsyncSession):
project = Project(name="Test Project")
session.add(project)
await session.commit()

task = Task(title="Project Task", project_id=project.id)
session.add(task)
await session.commit()

await session.refresh(task, ["project"])
assert task.project.name == "Test Project"

Step 4: Add Constraint Tests

@pytest.mark.asyncio
async def test_cascade_delete(session: AsyncSession):
project = Project(name="Delete Me")
session.add(project)
await session.commit()

task = Task(title="Child Task", project_id=project.id)
session.add(task)
await session.commit()
task_id = task.id

await session.delete(project)
await session.commit()

assert await session.get(Task, task_id) is None

@pytest.mark.asyncio
async def test_unique_constraint(session: AsyncSession):
project = Project(name="Unique Test")
session.add(project)
await session.commit()

task1 = Task(title="Same Title", project_id=project.id)
session.add(task1)
await session.commit()

task2 = Task(title="Same Title", project_id=project.id)
session.add(task2)

with pytest.raises(IntegrityError):
await session.commit()

Step 5: Run Tests

pytest tests/test_models.py -v

Expected Output:

========================= test session starts ==========================
collected 7 items

tests/test_models.py::test_create_task PASSED [ 14%]
tests/test_models.py::test_update_task PASSED [ 28%]
tests/test_models.py::test_delete_task PASSED [ 42%]
tests/test_models.py::test_task_project_relationship PASSED [ 57%]
tests/test_models.py::test_cascade_delete PASSED [ 71%]
tests/test_models.py::test_unique_constraint PASSED [ 85%]
tests/test_models.py::test_json_metadata_storage PASSED [100%]

========================== 7 passed in 0.22s ===========================

Try With AI

Prompt 1: Generate Model Tests

Here's my Task model:

class Task(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
description: str | None = None
status: str = "pending"
priority: str = "medium"
project_id: int | None = Field(foreign_key="project.id")
created_at: datetime = Field(default_factory=datetime.utcnow)

Generate model tests for: create with defaults, update status,
query by priority, and verify created_at is set.

What you're learning: AI generates model-level tests that work directly with the database session, separate from API tests.

Prompt 2: Fix Database Compatibility

Your test uses a JSONB query:

result = await session.exec(
select(Task).where(Task.metadata["status"].astext == "active")
)

I'm testing with SQLite. JSONB queries don't work. Suggest an
alternative that works with both SQLite and PostgreSQL.

What you're learning: You catch database-specific patterns that AI might miss. The solution involves storing JSON as text and querying differently.

Prompt 3: Design Constraint Tests

My Task model has these constraints:
- title is required (NOT NULL)
- (project_id, title) must be unique together
- project_id must reference existing project

Help me design tests for each constraint. For the unique constraint,
show me how to verify the exact error message so I know it's the
right constraint failing.

What you're learning: Constraint tests verify database integrity rules are actually enforced.


Reflect on Your Skill

Test Your Skill

Using my agent-tdd skill, show me how to test SQLModel cascade
deletes. Does my skill include in-memory SQLite setup with StaticPool?

Identify Gaps

Review your skill's response:

  • Does it explain why StaticPool is required for in-memory SQLite?
  • Does it show the autouse=True pattern for database setup?
  • Does it include IntegrityError handling for constraint tests?
  • Does it address SQLite vs PostgreSQL differences?

Improve Your Skill

My agent-tdd skill needs SQLModel testing patterns. Add:
1. In-memory SQLite setup with StaticPool and explanation
2. Database fixture with autouse=True for table creation/cleanup
3. Cascade delete testing pattern
4. Constraint violation testing with IntegrityError
5. Note about SQLite vs PostgreSQL JSON differences