1

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 ?

  1. Find a way to setup the compatibility between my prod Postgres DB and an in-memory SQLite DB ?
  2. Apply my tests on a preprod Postgres DB and try to cleanup the added/removed items ? (what I did actually but I don't think it is a really good practice)
  3. Setup a local Postgres server inside a Docker container ?
  4. Mock a DB with kind of a Dict in the pytest test file ?
  5. Use a third lib like testcontainers for exemple ?
  6. Don't do tests ?

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.

FloCAD
  • 113
  • 3
  • 13
  • 1
    _"I'd like to do unit and integration tests so maybe there is not only one solution"_ Absolutely. IMO no database (not even SQLite) should ever be touched _at all_ in any **unit test** whatsoever (unless you are writing a database engine of course). Side effects are haram. Use proper mocks. Test your own logic, not the database engine library. With integration tests however, it is almost the complete opposite. Try to emulate the production environment as closely as possible. Still, I am afraid this question is too opinion-based for SO. – Daniil Fajnberg Jun 22 '23 at 11:30
  • Alright @DaniilFajnberg but that's what I don't understand. If I want to unit test the create_new_item() in crud/items.py or the create_item() in the routers/items.py each time I have dependencies to the DB session ... Sorry if it's too opinion-based but I though I was just missing something with FastAPI ! – FloCAD Jun 22 '23 at 12:35
  • Yes, you simply mock the `Session` object. E.g. using [`unittest.mock.create_autospec`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.create_autospec). Then ensure the expected calls were made via [`assert_called_once_with`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_once_with) for example. – Daniil Fajnberg Jun 22 '23 at 12:38
  • I'm really sorry but I don't get it... I tried for the routers/items.py the following but it doesn't makes any sense ... def test_create_item(): data = {"city_name": "London"} mocked_create_item = create_autospec(create_item, return_value=data) mocked_create_item(get_session(), data) mocked_create_item.assert_called_once_with(get_session(), data) --EDIT-- sorry for the non-indentation available in the comments – FloCAD Jun 22 '23 at 13:13
  • 1
    Since there are a lot of questions in one here (and this thread is closed anyway), I would suggest you ask a separate question focusing _only_ on specifically how you should write a unit test for the `create_new_item` function in such a way that you mock out the database engine and properly isolate the unit being tested. I can prepare a suggestion/answer for you. – Daniil Fajnberg Jun 22 '23 at 13:30
  • Yes, you're right, it will be easier. I just put my question here : https://stackoverflow.com/questions/76533018/unit-tests-in-fastapi Thank you for your answers so far! – FloCAD Jun 22 '23 at 14:35

1 Answers1

3

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):

  1. I wouldn't do that because it may be too much work and result in very brittle tests. If you work around the compatibility issues, if/when things break you will have trouble finding out what the real issue is.
  2. This is a valid approach: You can implement a set of integration tests with predictable results, deploy your code in a Staging environment and run your suite against that. You can even run these tests in a step of your CI/CD pipeline for automation.
  3. That is a valid approach for local DEV: Use this for your local development and testing of new implementations. You can use this for Unit Testing, but you must cleanup after each run (probably write scripts that automate the cleanup on teardown) to avoid residues and your tests failing or passing unexpectedly.
  4. That is a valid approach but doesn't work that well for testing the DB. This I am usually using for testing the API and "postprocessing" methods on data that are fetched from the DB. In other words, just because the mocks work, doesn't mean that the end result will. Read an interesting article about this btw.
  5. This is a valid approach: testcontainers provides a containerized DB that spawns for testing, we can add schemas and tables to it without fear (if something breaks here, means that the testing caught an issue early) and then it gets despawned afterward. I would use this approach for this case! (here is a basic example of using it with pytest)
  6. Fear leads to not using tests, not using tests leads to many sleepless nights, and many sleepless nights lead to more issues. This is the path to the dark side (and many lost days and even weekends).

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 :)

John Moutafis
  • 22,254
  • 11
  • 68
  • 112
  • 1
    I mostly agree with your answers to the six questions. I _strongly_ disagree with the article you cite in 4); I think the author just misuses mocks and therefore argues against a straw man. My opinion is that _in general_ mocks should be used _extensively_ in unit tests and _sparingly_ (or not at all) in integration/e2e tests. Loved point 6) though. – Daniil Fajnberg Jun 22 '23 at 11:23
  • 1
    @DaniilFajnberg Mocks are an industry standard, I tend to use them mostly for testing non-DB related stuff (like an external API response or anything 3rd party related to the app). For data testing, I prefer test DBs and/or integration tests to see the whole picture! Thank you for the 6th :D – John Moutafis Jun 22 '23 at 12:05
  • 1
    @JohnMoutafis Thanks a lot for your time all those explanations ! It was really clear ! Also thx for SQLModel tips ! I certainly misunderstood the way to implement the ItemCreate, ItemUpdate and so on so I'll check the documentations ! – FloCAD Jun 23 '23 at 09:54
  • 1
    @FloCAD happy to help :) and don't get discouraged, all of us have been where you are at some point or another! – John Moutafis Jun 23 '23 at 10:18