A small automated test can catch a broken response before a user does. Build a tiny FastAPI service, then check its happy path, rejected inputs, and boundary values with repeatable tests.
A calculator endpoint returns a perfectly reasonable answer in your browser. You change one line, refresh, and it still looks fine. Then a different caller sends zero items, a number inside quotation marks, or an unexpected field. Your quick check never covered those cases.
An API test turns an expectation into something the computer can check again after every change. For this lesson, we will build a fictional notebook quote service. It does no purchasing, stores no records, and contacts no external service. That keeps attention on one question: does the endpoint behave as promised?
Start with a promise you can test
Our endpoint accepts a notebook quantity from 1 through 20. Each notebook costs 250 cents. The request must contain an integer quantity and no additional fields. A successful response contains the same quantity and the total in cents.
Using whole cents makes this particular example easy to check exactly. A production pricing system would need explicit currency, tax, discounts, and rounding rules. None of those are being modeled here.
The contract gives us several test cases before we write implementation code: quantity 3 should produce 750 cents; quantities 1 and 20 should work; 0 and 21 should fail; a missing quantity should fail. We also choose to reject the string "3" and the Boolean true. They are not the integer input this API promises to accept.
Build the smallest useful service
Use a fresh virtual environment. The accompanying requirements.lock was tested with Python 3.14.4; install it with python -m pip install -r requirements.lock. Other Python versions have not been tested with this lock. Save the following as app.py. This example uses Pydantic version 2 syntax.
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field
app = FastAPI()
class QuoteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity: int = Field(strict=True, ge=1, le=20)
class QuoteResponse(BaseModel):
quantity: int
total_cents: int
@app.post("/quote", response_model=QuoteResponse)
def quote(request: QuoteRequest):
return {
"quantity": request.quantity,
"total_cents": request.quantity * 250,
}
The request model defines the input rules. Field supplies the inclusive lower and upper bounds. Strict validation avoids accepting a numeric string as an integer in this field. Forbidding extras catches a misspelled or unintended field rather than quietly ignoring it. These are deliberate choices for this exercise, not universal rules for every API. See the official FastAPI field guide and Pydantic strict-mode guide.
Ask the endpoint concrete questions
Save this beside app.py as test_app.py. FastAPI’s TestClient lets the tests call the application without starting a separate web server. The official testing guide explains this pattern.
import pytest
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
@pytest.mark.parametrize("quantity", [1, 3, 20])
def test_valid_quote(quantity):
response = client.post("/quote", json={"quantity": quantity})
assert response.status_code == 200
assert response.json() == {
"quantity": quantity,
"total_cents": quantity * 250,
}
@pytest.mark.parametrize("payload", [
{}, {"quantity": 0}, {"quantity": 21},
{"quantity": "3"}, {"quantity": True},
{"quantity": 1.5}, {"quantity": None},
{"quantity": 3, "discount": 100},
])
def test_invalid_quote(payload):
response = client.post("/quote", json=payload)
assert response.status_code == 422
For this checked example, use the accompanying requirements.lock in a fresh virtual environment: python -m pip install -r requirements.lock, then python -m pytest -q. The supplied lock was tested on Python 3.14.4 with FastAPI 0.141.1, Starlette 1.6.0, HTTPX 0.28.1, Pydantic 2.13.4 and pytest 8.4.2: all eleven cases passed. This combination emits deprecation warnings for HTTPX and the AnyIO portal alias. HTTPX2 migration has not been tested. These parameterized functions create eleven cases: three accepted inputs and eight rejected inputs. A passing run means these cases satisfied these assertions in this environment. It does not certify the entire service.
Notice that the successful test checks both the status and the complete response. A 200 response containing the wrong total would still fail. The invalid-input tests check the rejection status without tying the lesson to the exact wording of framework-generated error messages.
Download the tested requirements.lock · Download app.py · Download test_app.py
Make a mistake on purpose
Change the multiplier in app.py from 250 to 25, leaving the tests alone. Run the suite again. The three valid-quote cases should fail because the expected price has not changed.
Restore 250. Then try changing le=20 to le=200. The test for quantity 21 should fail because the application now accepts an input the contract rejects. This short experiment shows why a green test run is more useful when you have also seen the tests catch a real mistake.
Avoid changing the expected answer simply to make a failure disappear. First decide whether the implementation is wrong or the intended contract has genuinely changed. If the contract changes, review callers and documentation as well as tests.
Know what remains untested
This exercise covers a local application boundary. It does not test authentication, network failures, deployed configuration, concurrent requests, or database behavior. Those need their own scenarios when the service grows.
AI can help brainstorm missing cases or explain a failure. Keep the pass-or-fail decision in ordinary assertions when the answer is exact. Asking a model whether 3 times 250 “looks right” adds uncertainty to a calculation that software can check directly.
For your next endpoint, write down one normal input, two boundaries, and one input that must be rejected. Build those tests before adding more features. You will have a small, reusable record of what callers are entitled to expect.