Using a fake database and tearing it down at the end of your tests will definitely work, but I find it is usually better to implement transactional testing when testing routes that rely on databases. The process looks something like this:
- In the session scope, create a PyTest fixture that returns a Motor client for all our tests to use.
- In the function scope, create a Pytest fixture that yields a Mongo session and transaction for each test to use. This transaction should rollback when the test ends to maintain a pure state of the underlying database.
- Use FastAPI overrides so that our application uses our custom Motor client and our custom session for the tests.
This approach has some benefits, namely that we can manipulate data in each individual test case without affecting other tests. It is also faster than actually writing and deleting data from the database since all the data manipulation is done in a transaction that is never committed.
To get this all working, the trick is to make sure that FastAPI and Motor are using the same event loop. Otherwise, you may run into issues described in this note in the FastAPI docs. Also, make sure to install a PyTest Async extension like pytest-asyncio
and either mark your tests and fixtures appropriately or use --asyncio-mode=auto
when calling PyTest or in your pytest.ini
.
Here is a basic example of an implementation that works for me:
The Motor test client
import pytest
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
@pytest.fixture(scope="session")
def mongodb_client():
client = AsyncIOMotorClient(
...your connection settings
)
# make sure the client always uses the current event loop instead of creating a new one
# this will ensure the application and the test are running in the same loop
client.get_io_loop = asyncio.get_event_loop
yield client
client.close()
The Mongo test session
import pytest
from motor.core import AgnosticClient
@pytest.fixture
async def mongo_session(mongodb_client: AgnosticClient):
async with await mongodb_client.start_session() as session: # wrap each test in its own session
session.start_transaction() # wrap each test in its own transaction
yield session
await session.abort_transaction() # abort at the end of the test so nothing is written to our database
Now that we have these fixtures, we can override dependencies in our app. This part will vary depending on how you structured your own FastAPI application. My application has dependencies that return my client and session so I can just override them. I find this works well, since then each of my routes in my application can use the client and session like so:
@router.get("/{some_id}")
async def get_some_thing(some_id: str, mongo_client: Annotated[AgnosticClient, Depends(get_mongo_client], mongo_session: Annotated[AgnosticClientSession, Depends(get_mongo_session)]):
collection = mongo_client["some database"].get_collection("some collection")
return await collection.find_one({"some_id": some_id}, session=mongo_session)
Using this approach to my application, the TestClient
fixture can just override the relevant dependencies:
import pytest
from motor.core import AgnosticClient, AgnosticClientSession
from fastapi.testclient import TestClient
@pytest.fixture
def client(
mongodb_client: AgnosticClient,
mongo_session: AgnosticClientSession,
):
def override_get_mongo_client():
return mongodb_client
def override_get_mongo_db_session():
return mongo_session
app.dependency_overrides[get_mongo_client] = override_get_mongo_client
app.dependency_overrides[get_mongo_db_session] = override_get_mongo_db_session
return TestClient(app)
With all this in place, we can use the client and session in our tests and know that the FastAPI application is also using the same client and session. This allows us to insert fake documents in each test, and then use those fake documents when accessing our CRUD routes, etc.
app.dependency_overrides
to provide a custom function that returns a fake/static user in your tests. fastapi.tiangolo.com/advanced/testing-dependencies -app.dependency_overrides[get_current_user] = lambda: return {'id': 1, 'username': 'foo'}
– Cumuliform