Home/Learn/FastAPI/TestClient — Testing Endpoints with pytest

TestClient — Testing Endpoints with pytest

Intermediate
Testing

TestClient calls your app in-process with the requests API — no server, no network. Test the contract: status codes, response shapes, validation rejections, and auth gates, with pytest.mark.parametrize doing the heavy lifting.

Overview

TestClient (httpx under the hood) speaks directly to your ASGI app in memory: client.get("/students/1") runs the full stack — routing, dependencies, validation, serialization — without Uvicorn or a port, fast enough for hundreds of tests per second. What you assert is the API contract, not the implementation: correct status codes per scenario (200/201/404/409/422/401), response JSON shape, validation rejecting exactly the bad inputs (parametrize shines here), and auth gates actually gating. The habits that keep a test suite honest: one behaviour per test with a name that reads as a sentence, arrange-act-assert structure, and testing through the public HTTP surface rather than reaching into internals — refactors then pass tests unchanged, and failures mean real breakage. This chapter tests stateless behaviour; databases and auth swapping arrive with fixtures in the next one.

The Shape of Endpoint Tests

Instantiate once, call endpoints, assert on status and JSON. Happy path, sad path, and the auth gate — the trio every resource deserves.

Status + shape + the leak check + both sides of the auth gate
# pip install pytest httpx
# tests/test_students.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)          # module-level is fine for stateless tests

def test_get_student_returns_profile():
    resp = client.get("/students/1")

    assert resp.status_code == 200
    body = resp.json()
    assert body["name"] == "Asha"
    assert "password" not in body           # response_model filtering WORKS
    assert "hashed" not in body             # (the leak test worth writing)

def test_missing_student_is_404_with_detail():
    resp = client.get("/students/99999")
    assert resp.status_code == 404
    assert "not found" in resp.json()["detail"]

def test_create_student_returns_201_and_id():
    resp = client.post("/students", json={
        "name": "Ravi", "email": "ravi@coep.ac.in",
        "branch": "IT", "cgpa": 7.9,
    })
    assert resp.status_code == 201
    assert resp.json()["id"] > 0

def test_protected_route_rejects_anonymous():
    resp = client.get("/admin/students")            # no Authorization header
    assert resp.status_code == 401

def test_protected_route_accepts_token():
    resp = client.get("/admin/students",
                      headers={"Authorization": "Bearer test-tpo-token"})
    assert resp.status_code == 200

# Naming: test_<what>_<expectation> — failures read as sentences
# in CI output: "test_missing_student_is_404_with_detail FAILED" tells
# the story before you open the file.

Parametrize the Validation Surface

Validation rules are tables of cases — write them as tables. One parametrized test documents and enforces every boundary; the 422 body assertions pin WHICH field failed.

Validation rules as parametrize tables — every boundary pinned
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

VALID = {"name": "Asha", "email": "asha@nitk.edu.in",
         "branch": "CS", "cgpa": 8.7}

@pytest.mark.parametrize("field,bad_value,why", [
    ("email",  "not-an-email",  "malformed email"),
    ("email",  None,            "email required"),
    ("cgpa",   11.0,            "cgpa above 10"),
    ("cgpa",   -1.0,            "cgpa negative"),
    ("name",   "A",             "name too short"),
    ("branch", "AERO",          "unknown branch enum"),
])
def test_create_rejects_invalid(field, bad_value, why):
    payload = {**VALID, field: bad_value}
    if bad_value is None:
        payload.pop(field)

    resp = client.post("/students", json=payload)

    assert resp.status_code == 422, why
    # pin WHICH field failed — not just "some 422 happened":
    failed_fields = [e["loc"][-1] for e in resp.json()["detail"]]
    assert field in failed_fields

@pytest.mark.parametrize("cgpa", [0.0, 10.0])       # boundaries are VALID
def test_cgpa_boundaries_accepted(cgpa):
    resp = client.post("/students", json={**VALID,
                       "email": f"b{cgpa}@x.in", "cgpa": cgpa})
    assert resp.status_code == 201

# 8 scenarios, 2 test functions. Adding a rule = adding a table row.
# The table IS the documentation of your validation contract —
# and ge=0, le=10 boundaries get the exact-edge tests they deserve.

Key Points to Remember

  • 1TestClient runs the app in-process: full stack, no server, hundreds of tests/second
  • 2Test the contract: status codes, JSON shape, and the password-leak check
  • 3parametrize turns validation rules into readable, exhaustive tables
  • 4Assert WHICH field caused the 422 via loc, not just that one happened

Interview Questions

Sign in to ask Aria
1

What layers execute when TestClient calls an endpoint? What is NOT covered?

MediumBrowserStack
2

Write the test that proves password hashes can never leak from any user endpoint.

MediumPhonePe
3

Your team argues unit vs API tests for a FastAPI service — what does each catch that the other misses?

HardAtlassian

Ask Aria about TestClient — Testing Endpoints with pytest

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…