FastAPI’s TestClient doesn’t just mock HTTP requests; it actually runs your FastAPI application in memory, allowing for incredibly fast and realistic integration tests without needing to spin up a separate server.
Here’s a FastAPI app and a simple test:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str = None):
return {"item_id": item_id, "q": q}
@app.post("/items/")
async def create_item(item: dict):
return {"item_received": item}
# test_main.py
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"Hello": "World"}
def test_read_item():
response = client.get("/items/5?q=somequery")
assert response.status_code == 200
assert response.json() == {"item_id": 5, "q": "somequery"}
def test_create_item():
response = client.post("/items/", json={"name": "Test Item", "price": 19.99})
assert response.status_code == 200
assert response.json() == {"item_received": {"name": "Test Item", "price": 19.99}}
When you run pytest, TestClient hooks into your FastAPI app directly. It uses httpx under the hood, but instead of sending requests over the network, it calls the ASGI application (app in this case) directly with a simulated request object. This bypasses the entire network stack, making tests run in milliseconds.
The TestClient instance client becomes your go-to for interacting with your API during tests. You can use its methods like client.get(), client.post(), client.put(), client.delete(), etc., just as you would with a real HTTP client. The responses you get back are httpx.Response objects, so you have access to response.status_code, response.json(), response.text, and headers.
For path parameters like /items/{item_id}, you pass the values directly in the URL string: client.get("/items/5"). Query parameters are appended to the URL as usual: client.get("/items/5?q=somequery"). For POST, PUT, and other methods that send a request body, you use the json argument to send Python dictionaries, which TestClient serializes to JSON for you.
You can also test request bodies that are Pydantic models, not just raw dictionaries. If your endpoint expects a Pydantic model:
# main.py (continued)
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
is_offer: bool = None
@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item):
return {"item_name": item.name, "item_id": item_id}
Your test would look like this:
# test_main.py (continued)
from .main import Item
def test_update_item():
test_item_data = {"name": "Updated Item", "price": 29.99, "is_offer": True}
response = client.put("/items/10", json=test_item_data)
assert response.status_code == 200
assert response.json() == {"item_name": "Updated Item", "item_id": 10}
TestClient handles the validation and serialization seamlessly.
The real power comes when you need to test authentication, dependencies, or other more complex scenarios. You can use client.get() with an auth parameter, or even override dependencies for specific tests. For example, to test an endpoint that requires authentication:
# main.py (continued)
from fastapi import Depends, HTTPException
async def get_current_user(token: str = "test_token"):
if token != "valid-token":
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
return {"username": "foo"}
@app.get("/users/me")
async def read_users_me(current_user: dict = Depends(get_current_user)):
return current_user
And the test:
# test_main.py (continued)
def test_read_users_me_unauthenticated():
response = client.get("/users/me")
assert response.status_code == 401
assert response.json() == {"detail": "Invalid authentication credentials"}
def test_read_users_me_authenticated():
# This is where dependency overriding shines
# We'll override get_current_user to return a fixed user for this test
with client.context(dependency_overrides={get_current_user: lambda: {"username": "testuser"}}):
response = client.get("/users/me")
assert response.status_code == 200
assert response.json() == {"username": "testuser"}
The client.context manager is a neat trick for temporarily overriding dependencies for a block of code. This allows you to isolate the behavior of a single endpoint without needing to set up complex authentication flows for every test.
You can also pass headers, cookies, and even files in your requests. For instance, to send a custom header:
def test_custom_header():
headers = {"X-Custom-Header": "my-value"}
response = client.get("/", headers=headers)
# You can then assert that your endpoint processed this header, if applicable
assert response.status_code == 200
When testing file uploads, you’d use client.post with the files argument:
# main.py (continued)
import shutil
from fastapi import File, UploadFile
@app.post("/files/")
async def create_file(file: UploadFile = File(...)):
try:
with open(f"temp_{file.filename}", "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
finally:
file.file.close()
return {"filename": file.filename, "content_type": file.content_type}
# test_main.py (continued)
import io
def test_file_upload():
with io.BytesIO(b"some binary content") as fp:
fp.seek(0) # Ensure the stream is at the beginning
response = client.post("/files/", files={"file": ("test.txt", fp, "text/plain")})
assert response.status_code == 200
assert response.json() == {"filename": "test.txt", "content_type": "text/plain"}
# Clean up the created file
import os
if os.path.exists("temp_test.txt"):
os.remove("temp_test.txt")
The files argument expects a dictionary where keys are the form field names ("file" in this case). The values are tuples of (filename, file_object, content_type). The file_object should be a file-like object, often created with io.BytesIO for binary data or io.StringIO for text. Crucially, ensure your file_object is positioned at the beginning of the stream using seek(0) before passing it.
The underlying mechanism for TestClient is its ability to directly invoke the ASGI application. This means it handles request parsing, routing, dependency injection, and response generation exactly as your live application would, but within the Python process of your tests. It’s not a simulated HTTP request; it’s a real, albeit in-memory, execution of your ASGI app.
If you’re ever in a situation where TestClient seems to be behaving unexpectedly, especially with dependencies or middleware, remember that you can inspect the response object’s request attribute to see exactly what httpx (and thus TestClient) thought it was sending to your application. This can be invaluable for debugging complex interactions.
The next step after mastering TestClient is often exploring how to manage larger test suites with fixtures and how to integrate with testing databases.