I'm developing a backend app with FastAPI connected to a PostgreSQL database and I'm a bit stuck and lost with the tests good practices. I read a lot stackoverflow posts and blogs but I'm not really used to backend development and I still doesn't really understand what is the best practice.
Knowing that I use SQLModel, it is suggested in the documentation to perform the tests with a DB SQLite in memory. The problem is that when I follow the explained approach, I am struck by the non-compatibility between PG and SQLite (about schemas). The point is that I am consuming an existing DB with several schemas and not just a public schema. So, when I run my tests, I encounter the error "schema pouetpouet does not exist".
Finally, the question is: What should I do to test my app ?
After all, I'd like to do unit and integration tests so maybe there is not only one solution about my needs.
Here is a really simplified version of my project:
The architecture of my project: (Consider that there is an __ init__.py file in each folder)
app/
├── api/
│ ├── core/
│ │ ├── config.py #get the env settings and distribute it to the app
│ │ ├── .env
│ ├── crud/
│ │ ├── items.py #the CRUD functions called by the router
│ ├── db/
│ │ ├── session.py #the get_session function handling the db engine
│ ├── models/
│ │ ├── items.py #the SQLModel object def as is in the db
│ ├── routers/
│ │ ├── items.py #the routing system
│ ├── schemas/
│ │ ├── items.py #the python object def as it is used in the app
│ ├── main.py #the main app
├── tests/
│ ├── test_items.py #the pytest testing file
In the crud/items.py:
from fastapi.encoders import jsonable_encoder
from sqlmodel import Session, select
from api.models import Item
from api.schemas import ItemCreate
def get_item(db_session: Session, item_id: int) -> Item:
query = select(Item).where(Item.id == item_id)
return db_session.exec(query).first()
def create_new_item(db_session: Session, *, obj_input: ItemCreate) -> Item:
obj_in_data = jsonable_encoder(obj_input)
db_obj = Item(**obj_in_data)
db_session.add(db_obj)
db_session.commit()
db_session.refresh(db_obj)
return db_obj
In the db/session.py:
from sqlalchemy.engine import Engine
from sqlmodel import create_engine, Session
from api.core.config import settings
engine: Engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
def get_session() -> Session:
with Session(engine) as session:
yield session
In the models/items.py:
from sqlmodel import SQLModel, Field, MetaData
meta = MetaData(schema="pouetpouet") # https://github.com/tiangolo/sqlmodel/issues/20
class Item(SQLModel, table=True):
__tablename__ = "cities"
# __table_args__ = {"schema": "pouetpouet"}
metadata = meta
id: int = Field(primary_key=True, default=None)
city_name: str
In the routers/items.py:
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from api.crud import get_item, create_new_item
from api.db.session import get_session
from api.models import Item
from api.schemas import ItemRead, ItemCreate
router = APIRouter(prefix="/api/items", tags=["Items"])
@router.get("/{item_id}", response_model=ItemRead)
def read_item(
*,
db_session: Session = Depends(get_session),
item_id: int,
) -> Item:
item = get_item(db_session=db_session, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
@router.post("/", response_model=ItemRead)
def create_item(
*,
db_session: Session = Depends(get_session),
item_input: ItemCreate,
) -> Item:
item = create_new_item(db_session=db_session, obj_input=item_input)
return item
In the schemas/items.py:
from typing import Optional
from sqlmodel import SQLModel
class ItemBase(SQLModel):
city_name: Optional[str] = None
class ItemCreate(ItemBase):
pass
class ItemRead(ItemBase):
id: int
class Config:
orm_mode: True
In the tests/test_items.py:
from fastapi.testclient import TestClient
from api.main import app
client = TestClient(app)
def test_create_item() -> None:
data = {"city_name": "Las Vegas"}
response = client.post("/api/items/", json=data)
assert response.status_code == 200
content = response.json()
assert content["city_name"] == data["city_name"]
assert "id" in content
ps: not being very experienced in backend development, do not hesitate to bring constructive remarks about my code if you notice something strange. It will be very well received.
Testing is always a confusing matter when you begin with it, with many opinions and few things set in stone.
That said your questioning approach seems correct and thought out so I will try to answer (keep in mind that this comes from personal preference and experience on writing tests for a multitude of applications so far, so it will be mostly opinionated):
Finally, keep in mind that there are no "silver bullets" here so different approaches can be used together.
For example in your case, I would go with testcontainers for local and pre-commit testing and then run an integration test suite in a Staging environment before deploying to PROD.
Your approach to the code seems good at a glance.
A minor remark here: Since you are using SQLModel you can avoid the separate SQLAlchemy models
& Pydantic schemas
that are used for the "traditional" FastAPI approach with SQLAlchemy.
You can use the SQLModels directly as schemas instead as demonstrated in the documentation
Hope this helps you to find a good solution for your testing :)