Unit Tests for Models & Schemas

Writing Unit Tests for Models and Schemas — FastAPI Backend Development.

Focus: writing unit tests for models and schemas

Sponsored

You've built your Pydantic schemas and SQLAlchemy models, and everything works when you manually poke at the API. But the moment you refactor that UserCreate schema or add a column to Order, a silent validation error or a database integrity bug slips through the cracks — and you only find out when a customer hits a 422 or 500 in production. That's the pain this lesson kills: writing unit tests for models and schemas so that every validation rule, default value, and database constraint is proven to work before your code ever ships.

The problem this lesson solves

FastAPI leans on two layers of truth: your Pydantic schemas define the shape and rules of data crossing the API boundary, and your SQLAlchemy models define how that data lives in the database. If those two layers drift apart — say, a schema allows user_id as a string but the model expects an integer — you get runtime explosions that are painful to trace.

Manual testing via the interactive Swagger docs is fine for a quick sanity check, but it scales terribly. You forget the edge cases: what happens when an email is malformed? What about a required field left blank? Does your model's created_at default actually fire?

Unit tests trap these issues at the earliest, cheapest moment. They give you a contract that runs on every pytest invocation, so a change in one file that breaks another layer is caught in seconds, not days. Without them, your 'simple refactor' becomes a treasure hunt through stack traces.

Core concept / mental model

Think of your application as a three-tier sandwich:

  1. Schemas (Pydantic) — the frontier guards. They decide who gets in and with what luggage. They validate types, lengths, formats.
  2. Models (SQLAlchemy) — the warehouse blueprint. They decide what the shelves look like and what's stored where.
  3. Database — the warehouse itself.

Unit tests for schemas verify the guards' decisions: is this person allowed in with that bag? Unit tests for models verify the blueprint: if a shelf says "must have a timestamp," does the warehouse actually stamp it?

The key distinction: schema tests run without a database (pure Pydantic logic), while model tests need a database session (in-memory SQLite is perfect). This split keeps tests fast and focused.

A useful mental model for testing models is the state-transition approach: create an instance in memory, assert its attributes are sensible, then ask the database to commit it and assert the persisted row matches.

How it works step by step

Here's the logical flow for writing these tests in any FastAPI project:

  1. Set up the test environment — install pytest and optionally httpx for later API tests. Create a tests/ directory.
  2. Isolate the database — use an in-memory SQLite database and a fixture that creates tables fresh for each test.
  3. Write schema tests — instantiate your Pydantic models with valid and invalid data. Use pytest.raises(ValidationError) to assert failures.
  4. Test model defaults and relationships — create a model instance, check attributes, then commit and verify the database row.
  5. Run and iterate — watch tests fail on purpose (red), then make them pass (green).

Step 1: Layout and dependencies

Start with the standard test layout:

project/
├── app/
│   ├── models.py
│   └── schemas.py
├── tests/
│   ├── conftest.py
│   ├── test_schemas.py
│   └── test_models.py
└── requirements.txt

Your requirements.txt should include pytest and pytest-asyncio if you use async tests.

Step 2: The database fixture (conftest.py)

For model tests, create a fixture that spins up an in-memory SQLite database, creates all tables, and yields a session:

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base

@pytest.fixture
def db_session():
    engine = create_engine(
        "sqlite://",  # in-memory
        connect_args={"check_same_thread": False},
    )
    Base.metadata.create_all(bind=engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    try:
        yield session
    finally:
        session.close()
        Base.metadata.drop_all(bind=engine)

Blockquote: Pro tip: Using sqlite:// gives you an isolated database per test — no stray data bleeding between tests.

Step 3: Writing schema tests

Pydantic schemas are plain classes; you test them like any other class. Here's an example for a UserCreate schema:

from pydantic import ValidationError
import pytest
from app.schemas import UserCreate

class TestUserCreateSchema:
    def test_valid_input(self):
        user = UserCreate(
            username="alice",
            email="alice@example.com",
            password="secret123"
        )
        assert user.username == "alice"
        assert user.email == "alice@example.com"

    def test_invalid_email_rejected(self):
        with pytest.raises(ValidationError):
            UserCreate(
                username="bob",
                email="not-an-email",
                password="secret"
            )

    def test_missing_required_field(self):
        with pytest.raises(ValidationError):
            UserCreate(username="carol", password="secret")

    def test_default_value_applied(self):
        # Assume schema has: is_active: bool = True
        user = UserCreate(
            username="dave",
            email="dave@example.com",
            password="pass"
        )
        assert user.is_active is True

Run it with pytest tests/test_schemas.py -v and watch all tests pass.

Step 4: Writing model tests

Now test the SQLAlchemy model — its defaults, relationships, and actual persistence. Our model looks like:

from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    username = Column(String, unique=True, nullable=False)
    email = Column(String, nullable=False)
    created_at = Column(DateTime, server_default=func.now())

And the test:

from app.models import User

def test_user_created_at_default(db_session):
    user = User(username="alice", email="alice@example.com")
    db_session.add(user)
    db_session.commit()

    # We didn't set created_at, but the server default should have filled it.
    assert user.created_at is not None
    assert user.id is not None  # primary key assigned by the database

def test_user_username_unique_constraint(db_session):
    user1 = User(username="alice", email="alice@example.com")
    user2 = User(username="alice", email="alice2@example.com")
    db_session.add(user1)
    db_session.commit()  # succeeds

    db_session.add(user2)
    with pytest.raises(Exception):  # SQLAlchemy raises IntegrityError
        db_session.commit()

Step 5: Running the suite

pytest tests/ -v --maxfail=1

Expect output like:

tests/test_schemas.py::TestUserCreateSchema::test_valid_input PASSED
tests/test_schemas.py::TestUserCreateSchema::test_invalid_email_rejected PASSED
...
tests/test_models.py::test_user_created_at_default PASSED
tests/test_models.py::test_user_username_unique_constraint PASSED

Now break a test on purpose: change the schema to allow an invalid email format temporarily, and watch the test fail. That's your safety net working.

Compare options / when to choose what

You have a few testing approaches beyond plain pytest

Approach Pros Cons Best For
Pure Pydantic tests (pytest) Fast, no DB, simple Can't catch DB issues Schema validation logic
In-memory SQLite Fast, zero config, good for models Doesn't match Postgres behavior exactly Model defaults, constraints, CRUD
Real Postgres (test DB) Most realistic Slower, requires setup Integration tests, production parity
Property-based tests (hypothesis) Explores edge cases automatically Overkill for basic schemas Complex validation rules, regex patterns

For most lessons in this track, in-memory SQLite is the sweet spot — it's fast, isolated, and good enough for unit testing models. You can graduate to a real Postgres test database when you start testing advanced queries or database-specific features.

Troubleshooting & edge cases

Even with a solid test setup, you'll hit pitfalls. Here's how to debug them:

  • OperationalError: no such table: users — your fixture didn't create tables. Make sure Base.metadata.create_all runs before your test uses the session.
  • Tests pass in one order but fail in another — you likely have database state leaking between tests. Always clean up: drop tables in a finalizer or use a fresh in-memory DB per test.
  • ValidationError not raised — double-check your schema's field names. If you expect email to be validated, but the field is contact_email, you're testing the wrong thing.
  • Async code in tests — use pytest-asyncio and mark tests with @pytest.mark.asyncio.
  • Model test passed but API returns a 422 — your schema defaults may differ from the model's constraints. For instance, a schema allows None but the model column is nullable=False. Write a test that checks model_validate(schema.model_dump()) to ensure the schema output is database-compatible.

What you learned & what's next

You've now got a repeatable way to verify that every schema rule and model constraint is enforced — catch issues at the unit level, not in production. You can explain the core idea behind writing unit tests for models and schemas, and you've completed a practical exercise with in-memory SQLite and pytest.

Next in the track: Now that you trust your data layer, move on to writing tests for your API endpoints — using FastAPI's TestClient to hit your routes and assert HTTP status codes and response bodies. That's the natural next step to complete your testing toolkit.

Practice recap

Add a new field to one of your existing schemas — for example, a phone_number with a regex pattern — and write tests that verify the valid and invalid cases. Then add a unique constraint to a model column and write a test that confirms a duplicate insert raises an error. Run pytest to see everything pass.

Common mistakes

  • Forgetting to set up the database tables in the test fixture, leading to 'no such table' errors.
  • Using the same database connection across multiple tests, causing state leakage and order-dependent failures.
  • Testing only valid inputs and skipping the pytest.raises(ValidationError) checks for invalid data.
  • Assuming the schema's defaults are the same as the model's defaults — they can drift silently, so test both layers.

Variations

  1. Use freezegun to freeze time and test default timestamps deterministically.
  2. Use hypothesis for property-based testing of schemas with complex regex or range rules.
  3. Use a transactional Session fixture with rollback to keep data isolated without recreating the engine.

Real-world use cases

  • A registration endpoint where UserCreate schema rejects weak passwords — unit test the validation rules.
  • An order model with a status column default — test that a new order starts as 'pending' after a commit.
  • A library management API where a unique constraint on book ISBN is tested to prevent duplicate entries.

Key takeaways

  • Unit test schemas in isolation without a database to validate input rules.
  • Test models against an in-memory SQLite database to verify defaults, constraints, and persistence.
  • Use pytest.raises to assert that invalid schema inputs raise ValidationError.
  • Keep database state isolated per test with fresh table creation and teardown.
  • Run tests frequently to catch drift between schemas and models early.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.