Project setup
First, create a new project and install the required testing dependencies:
uv init testcd testuv add fastapi[standard]uv add --dev pytest httpxFastAPI testing is built on HTTPX, which shares a similar design with the popular Requests library. This makes testing familiar and intuitive.
Since HTTPX is compatible with PyTest, you can use pytest directly to test your FastAPI applications.
TestClient
Create your FastAPI application in main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")async def get(): return {"message": "Hello World"}Then create a test file test_main.py:
from fastapi.testclient import TestClientfrom main import app
client = TestClient(app)
def test_get(): response = client.get("/") assert response.status_code == 200 assert response.json() == {"message": "Hello World"}Writing Tests
- Create a TestClient instance by passing your FastAPI application to it
- Define test functions with names starting with
test_(standardpytestconvention) - Make HTTP requests using the
TestClientobject the same way as you would withhttpx - Add assertions using standard Python
assertstatements to verify the expected behavior
Notice that test functions use regular def, not async def.
Client calls are also synchronous (no await needed).
This allows you to use pytest directly without complications.
Extended example
Let’s extend this example with more details to demonstrate testing different scenarios.
Suppose your main.py file now includes additional path operations:
- A
GEToperation that may return errors - A
POSToperation that may return multiple types of errors - Both operations require an
X-Tokenheader for authentication
from typing import Annotated
from fastapi import FastAPI, Header, HTTPExceptionfrom pydantic import BaseModel
secret_token = "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="
class Item(BaseModel): id: int title: str description: str | None = None
db: dict[int, Item] = { 1: Item(id=1, title="Laptop", description="A high-performance laptop for development"), 2: Item(id=2, title="Mouse", description="Wireless ergonomic mouse"), 3: Item(id=3, title="Keyboard"),}
app = FastAPI()
@app.get("/item/{item_id}", response_model=Item)async def item_get(item_id: int, x_token: Annotated[str, Header()]): if x_token != secret_token: raise HTTPException(status_code=400, detail="Invalid X-Token header") if item_id not in db: raise HTTPException(status_code=404, detail="Item not found") return db[item_id]
@app.post("/item", response_model=Item)async def item_post(item: Item, x_token: Annotated[str, Header()]): if x_token != secret_token: raise HTTPException(status_code=400, detail="Invalid X-Token header") if item.id in db: raise HTTPException(status_code=409, detail="Item already exists") db[item.id] = item return itemHere are comprehensive tests covering both success and error cases:
from fastapi.testclient import TestClientfrom main import app
client = TestClient(app)
def test_item_get(): response = client.get("/item/1", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="}) assert response.status_code == 200 assert response.json() == {"id": 1, "title": "Laptop", "description": "A high-performance laptop for development"}
def test_item_get_bad_token(): response = client.get("/item/1", headers={"X-Token": "Z1l5R0xqQk1yR1ZrU2RwX1NMS09mV2Ryb1p3b1h2b1E="}) assert response.status_code == 400 assert response.json() == {"detail": "Invalid X-Token header"}
def test_item_get_nonexistent_item(): response = client.get("/item/10", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="}) assert response.status_code == 404 assert response.json() == {"detail": "Item not found"}
def test_item_post(): response = client.post( "/item", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="}, json={"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"}, ) assert response.status_code == 200 assert response.json() == {"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"}
def test_item_post_bad_token(): response = client.post( "/item", headers={"X-Token": "Z1l5R0xqQk1yR1ZrU2RwX1NMS09mV2Ryb1p3b1h2b1E="}, json={"id": 5, "title": "USB-C Hub", "description": "7-in-1 hub with HDMI and Ethernet"} ) assert response.status_code == 400 assert response.json() == {"detail": "Invalid X-Token header"}
def test_item_post_existing_item(): response = client.post( "/item", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="}, json={"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"}, ) assert response.status_code == 409 assert response.json() == {"detail": "Item already exists"}Passing data to the TestClient
When you need to send data in requests, and you’re unsure of the syntax, you can reference the HTTPX documentation or even search for examples using the Requests library, since HTTPX’s API is based on Requests.
Common patterns:
| Data Type | How to Pass It |
|---|---|
| Path or query parameters | Add them directly to the URL |
| JSON body | Pass a Python object (e.g., a dict) to the json parameter |
| Form data | Use the data parameter instead of json |
| Headers | Pass a dict to the headers parameter |
| Cookies | Pass a dict to the cookies parameter |
Note that the TestClient receives data that can be converted to JSON, not Pydantic models.
If you have a Pydantic model in your test and you want to send its data to the application during testing, you can use the jsonable_encoder described in JSON Compatible Encoder.
Async tests
Why async tests?
Asynchronous tests are essential when your application interacts with async components like databases, external APIs, or message queues. For example, if you’re using an async database library, you’ll want to verify that your FastAPI endpoints correctly write data to the database.
However, this introduces a challenge: how do you test async operations effectively?
Installing dependencies
First, add the required testing dependency for async support:
uv add --dev trioUsing pytest.mark.anyio
To test asynchronous code, your test functions must also be asynchronous. The AnyIO library provides a pytest plugin that enables async test execution.
Mark your test functions with @pytest.mark.anyio to run them asynchronously.
Why you can’t use TestClient for async tests
Even if your FastAPI application uses regular def functions instead of async def, FastAPI is fundamentally asynchronous under the hood.
The TestClient uses internal mechanisms to call your asynchronous FastAPI application from synchronous test functions. However, this approach breaks down when your test function itself is asynchronous. When running tests with async def, you cannot use TestClient.
Instead, you’ll use HTTPX’s AsyncClient directly.
Using AsyncClient
Since TestClient is built on top of HTTPX, you can use HTTPX’s AsyncClient to test your API directly in async tests.
Update your test_main.py file:
import pytestfrom httpx import ASGITransport, AsyncClient
@pytest.mark.anyioasync def test_get_async(): async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as async_client: response = await async_client.get("/") assert response.status_code == 200 assert response.json() == {"message": "Hello World"}Key differences from synchronous tests:
- Test function declaration: Use
async definstead ofdef - Decorator: Add
@pytest.mark.anyiobefore the test function - Client: Use
AsyncClientinstead ofTestClient - Requests: Use
awaitwhen making requests - Context manager: Use
async withfor proper resource management
The AsyncClient doesn’t automatically trigger lifespan events (startup/shutdown). If your application depends on these events, use LifespanManager from florimondmanca/asgi-lifespan.
Testing with other async operations
Now that your test function is asynchronous, you can freely await other async operations within your tests. This is particularly useful for:
- Querying async databases
- Making external API calls
- Verifying background tasks
- Checking message queues
For example, you might test an endpoint and then verify the database state:
@pytest.mark.anyioasync def test_item_creates_db_entry(): async with AsyncClient as async_client: response = await async_client.post # Now you can await async database operations item = await db.get_item(10) # Async database call assert item.title == "New Item"If you encounter a RuntimeError: Task attached to a different loop when integrating asynchronous function calls in your tests (e.g. when using MongoDB’s MotorClient), remember to instantiate objects that need an event loop only within async functions, e.g. an @app.on_event("startup") callback.