Skip to content

TestClient Interview Questions & Answers

12 questions Updated 2026-06-20 Share:

FastAPI TestClient interview questions — requests-style testing, fixtures, status codes, headers, cookies and testing error responses.

12 of 12

TestClient (from starlette.testclient) wraps your FastAPI app in a requests-compatible interface. It runs the full ASGI stack synchronously so you can test endpoints without starting a real server.

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/items/{id}")
async def get_item(id: int):
    return {"id": id}

client = TestClient(app)

def test_get_item():
    response = client.get("/items/42")
    assert response.status_code == 200
    assert response.json() == {"id": 42}

Rule of thumb: create TestClient once per module (or as a pytest fixture) rather than on every test — instantiation is cheap but consistent fixture scope is cleaner.

Use TestClient as a context manager:

from fastapi.testclient import TestClient
from app.main import app

def test_with_lifespan():
    with TestClient(app) as client:
        # startup has run (DB connected, app.state populated)
        response = client.get("/health")
        assert response.status_code == 200
    # shutdown has run (DB disconnected)

Without the with block, lifespan events don't run, and any code that depends on app.state will fail.

Rule of thumb: always use with TestClient(app) as client: in tests that need startup resources; plain TestClient(app) is fine for stateless routes.

import pytest
from fastapi.testclient import TestClient
from app.main import app

@pytest.fixture(scope="module")
def client():
    with TestClient(app) as c:
        yield c

def test_list_items(client):
    resp = client.get("/items")
    assert resp.status_code == 200

def test_create_item(client):
    resp = client.post("/items", json={"name": "Widget", "price": 9.99})
    assert resp.status_code == 201

scope="module" starts the app once per module, which is faster than per-test. Use scope="function" if tests mutate state that must be reset.

Rule of thumb: use scope="module" for read-heavy test files; use scope="function" for tests that write to a database or modify app.state.

Pass the json= keyword argument — TestClient serialises it and sets Content-Type: application/json automatically:

def test_create_item(client):
    response = client.post(
        "/items",
        json={"name": "Widget", "price": 9.99},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "Widget"
    assert "id" in data

For raw string bodies: data='{"name":"Widget"}' with headers={"Content-Type": "application/json"}.

Rule of thumb: always use json= (not data=) for JSON payloads — it handles serialisation correctly and sets the content type header.

Pass headers= dict to the request method:

def test_authenticated(client):
    token = create_test_token(user_id=1)
    response = client.get(
        "/me",
        headers={"Authorization": f"Bearer {token}"},
    )
    assert response.status_code == 200

Or set default headers on the client itself:

@pytest.fixture
def auth_client(client):
    token = create_test_token(user_id=1)
    client.headers.update({"Authorization": f"Bearer {token}"})
    return client

Rule of thumb: create a separate auth_client fixture for tests that always need auth headers — it avoids repeating the header on every request.

Pass params= dict:

def test_search(client):
    response = client.get("/items", params={"q": "widget", "page": 2, "size": 10})
    assert response.status_code == 200
    # equivalent to GET /items?q=widget&page=2&size=10

For multi-value query params:

response = client.get("/items", params=[("tags", "python"), ("tags", "web")])
# GET /items?tags=python&tags=web

Rule of thumb: use params= dict for simple query strings; use a list of tuples when the same key appears multiple times.

Set cookies on the client or per-request:

# Per-request
def test_cookie_auth(client):
    response = client.get("/profile", cookies={"session": "valid-session-token"})
    assert response.status_code == 200

# Persistent across requests (simulates browser session)
client.cookies.set("session", "valid-session-token")
response = client.get("/profile")

TestClient automatically carries Set-Cookie headers between requests when used as a context manager — simulating a browser:

with TestClient(app) as client:
    client.post("/login", data={"username": "alice", "password": "secret"})
    # client now has the session cookie
    response = client.get("/profile")
    assert response.status_code == 200

Rule of thumb: use the with TestClient(app) context manager for tests that simulate a full login → use → logout flow.

Pass data= (not json=) for URL-encoded form data:

def test_login_form(client):
    response = client.post(
        "/token",
        data={"username": "alice", "password": "secret", "grant_type": "password"},
    )
    assert response.status_code == 200
    assert "access_token" in response.json()

For multipart file upload:

def test_file_upload(client):
    response = client.post(
        "/upload",
        files={"file": ("report.csv", b"id,name\n1,Alice", "text/csv")},
    )
    assert response.status_code == 200

Rule of thumb: use data= for form fields, files= for file uploads; never mix json= with data= in the same request.

Send an intentionally malformed request and assert on the 422 status code and error detail structure:

def test_invalid_price(client):
    response = client.post("/items", json={"name": "Widget", "price": "not-a-number"})
    assert response.status_code == 422
    errors = response.json()["detail"]
    assert any(e["loc"] == ["body", "price"] for e in errors)
    assert any(e["type"] == "float_parsing" for e in errors)

Check loc (where the error is) and type (what kind of error) rather than the msg string — messages can change between Pydantic versions.

Rule of thumb: assert on detail[*].loc and detail[*].type for validation errors — these are stable; msg wording changes between library versions.

TestClient re-raises server-side exceptions by default (raise_server_exceptions=True). This turns a 500 response into a Python exception in the test — useful for debugging but unhelpful when you're testing error handling.

# Test that a 500 is returned without raising
client = TestClient(app, raise_server_exceptions=False)

def test_internal_error():
    response = client.get("/broken")
    assert response.status_code == 500

Or use a context manager:

with TestClient(app, raise_server_exceptions=False) as client:
    resp = client.get("/broken")
    assert resp.status_code == 500

Rule of thumb: keep the default (True) for development tests so unhandled exceptions surface immediately; set False when specifically testing error-handling middleware.

TestClient follows redirects by default (follow_redirects=True). To test that a redirect is issued without following it:

def test_redirect(client):
    response = client.get("/old-path", follow_redirects=False)
    assert response.status_code == 301
    assert response.headers["location"] == "/new-path"

To disable redirect following globally:

client = TestClient(app, follow_redirects=False)

Rule of thumb: disable follow_redirects when testing the redirect itself (status code + Location header); keep it enabled when you care about the final destination response.

Use app.dependency_overrides:

from app.main import app
from app.db import get_db

def fake_get_db():
    db = FakeDatabase()
    db.users = [{"id": 1, "name": "Alice"}]
    yield db

app.dependency_overrides[get_db] = fake_get_db

client = TestClient(app)

def test_list_users():
    resp = client.get("/users")
    assert resp.status_code == 200
    assert len(resp.json()) == 1

Always clean up after the test:

@pytest.fixture(autouse=True)
def reset_overrides():
    yield
    app.dependency_overrides.clear()

Rule of thumb: put app.dependency_overrides.clear() in an autouse fixture teardown — stale overrides in one test silently corrupt subsequent tests.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel