FastAPI Testing with Pytest: A Practical API Testing Tutorial
Build a small FastAPI endpoint, test its success and validation contracts with pytest and TestClient, and learn what a useful API test should prove.
Article summary
TL;DR
- FastAPI’s
TestClientsends requests through your application in-process, so these tests do not need a running Uvicorn server. - Assert the response status and the meaningful JSON data that form the endpoint’s public contract.
- Cover a business boundary and invalid request bodies instead of stopping after one successful request.
- Use normal
deftests withTestClient; use an async client only when the test itself must await asynchronous work.
01 / API TESTING
A useful API test protects a request-and-response contract.
Calling an endpoint in Swagger UI and seeing a successful response is a useful manual check. It is not regression protection. The next code change can alter the status, rename a response field, or weaken request validation, and the manual check will not run itself.
An automated API test records the behavior that must remain true. It sends a request, receives a response, and verifies the parts of that exchange that clients depend on.
In this tutorial, we will test a small FastAPI application with pytest. The example follows FastAPI’s official testing pattern and uses TestClient, so requests travel through the application in-process. Routing, request validation, the endpoint, and response serialization all run, but you do not need to start Uvicorn or open a real network connection.
The path under test looks like this:
pytest test
-> TestClient request
-> FastAPI routing and request validation
-> endpoint logic
-> serialized HTTP response
That wider path is what makes this an API test rather than a direct unit test of shipping_cost().
The business rule comes from my beginner pytest tutorial: orders of 50 or more receive free shipping. You can follow this article on its own, but the earlier tutorial explains pytest discovery, plain assert, and the basic test feedback loop in more detail.
An API test should prove observable behavior at the application boundary, not repeat the endpoint's implementation.
Can pytest be used for API testing?
Yes. pytest provides test discovery, fixtures, parametrization, assertions, and failure reports. An HTTP client sends the requests. For an application you own, FastAPI’s TestClient provides that client without requiring a deployed server. For a live third-party API, you would use an HTTP client directly and keep the same pytest workflow around it.
By the end, you will have tests for a successful request, the exact free-shipping boundary, and invalid request bodies. That is a small suite, but every test has a distinct job.
02 / BUILD THE API
Build one FastAPI endpoint with a clear contract.
Create a new folder and install the application and test dependencies:
mkdir fastapi-testing-pytest
cd fastapi-testing-pytest
uv init --no-workspace
uv add fastapi
uv add --dev pytest httpx2
These commands use uv and create an independent project. FastAPI’s TestClient comes from Starlette, whose current client backend is the httpx2 package. Older tutorials may still show httpx, but that fallback is deprecated. If your project uses another environment manager, install FastAPI, pytest, and httpx2 there.
The finished project has two Python files plus its own dependency metadata and lockfile:
fastapi-testing-pytest/
├── main.py
├── test_main.py
├── pyproject.toml
└── uv.lock
You can also browse or clone the complete FastAPI testing example with pytest. It contains the same endpoint and the finished five-test suite from this tutorial.
Replace the contents of main.py with this application:
from fastapi import FastAPI
from pydantic import BaseModel, Field
class ShippingQuote(BaseModel):
order_total: int = Field(ge=0)
def shipping_cost(order_total: int) -> int:
if order_total >= 50:
return 0
return 5
app = FastAPI()
@app.post("/shipping-quotes")
async def create_shipping_quote(quote: ShippingQuote) -> dict[str, int]:
return {"shipping_cost": shipping_cost(quote.order_total)}
The endpoint accepts a JSON body with one integer field:
{"order_total": 75}
A valid request returns the calculated cost:
{"shipping_cost": 0}
Field(ge=0) makes non-negative totals part of the request contract. FastAPI validates the body before calling the endpoint. This gives us two kinds of behavior to test: our shipping rule and the framework boundary that rejects invalid input.
The application is intentionally small. A database, authentication layer, or external shipping service would add setup without changing the first API testing lesson.
03 / FIRST API TEST
Send the first request with TestClient.
Add this code to test_main.py:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_shipping_quote_returns_free_shipping():
response = client.post(
"/shipping-quotes",
json={"order_total": 75},
)
assert response.status_code == 200
assert response.json() == {"shipping_cost": 0}
The test follows the same arrange-act-assert flow as a unit test:
- Arrange: prepare an order total in the JSON request body.
- Act: send a POST request through
TestClient. - Assert: verify the HTTP status and response body.
Run the test from the project folder:
uv run pytest test_main.py -v
The useful part of the result should look similar to this:
test_main.py::test_shipping_quote_returns_free_shipping PASSED
The endpoint uses async def, but this test deliberately uses a normal def. FastAPI’s standard testing pattern lets TestClient handle the application call while pytest runs an ordinary synchronous test.
Checking only 200 would be incomplete. An endpoint can return the correct status with the wrong field name or value. The JSON assertion protects the response contract a caller actually uses.
04 / BOUNDARY CASES
Test the exact place where the API behavior changes.
The first test proves that 75 receives free shipping. It does not prove that the threshold itself is correct. A developer could accidentally change >= 50 to > 50, and the first test would still pass.
Import pytest at the top of test_main.py, then add a parametrized boundary test:
import pytest
@pytest.mark.parametrize(
("order_total", "expected_cost"),
[
pytest.param(49, 5, id="below-threshold"),
pytest.param(50, 0, id="at-threshold"),
],
)
def test_shipping_quote_handles_free_shipping_boundary(
order_total: int,
expected_cost: int,
):
response = client.post(
"/shipping-quotes",
json={"order_total": order_total},
)
assert response.status_code == 200
assert response.json() == {"shipping_cost": expected_cost}
pytest runs the same contract with the two values that matter most: one immediately below the threshold and one exactly on it. The IDs make each case recognizable in the test report.
This is where API testing and unit testing overlap in purpose. Both can protect the same business rule, but the API test proves more of the delivery path: JSON parsing, request validation, routing, the calculation, and response serialization.
That wider scope is useful, but it is also why API tests should remain selective. Keep a small number around important contracts rather than reproducing every low-level unit case through HTTP.
05 / INVALID REQUESTS
A useful API test suite includes requests that should fail.
Real clients eventually send missing or invalid data. Our request model says order_total is required and cannot be negative, so those conditions belong in the suite.
Add this parametrized test:
@pytest.mark.parametrize(
"payload",
[
pytest.param({"order_total": -1}, id="negative-total"),
pytest.param({}, id="missing-total"),
],
)
def test_shipping_quote_rejects_invalid_order_total(payload: dict[str, int]):
response = client.post("/shipping-quotes", json=payload)
assert response.status_code == 422
errors = response.json()["detail"]
assert any(error["loc"][-1] == "order_total" for error in errors)
FastAPI returns 422 Unprocessable Entity when the request body fails its validation rules. The test checks the status and confirms that the reported problem belongs to order_total.
It does not copy the entire generated error document into the assertion. Exact validation messages and metadata are usually framework details unless your API promises them as part of its public contract. Over-asserting that structure creates noisy failures during dependency upgrades without giving the application much more protection.
The same distinction applies to custom errors. If your documented API contract promises a particular error code or response shape, test it precisely. If the framework owns the wording, assert only the stable behavior your clients need.
06 / TESTCLIENT OR ASYNC
Keep the simple client until the test itself needs async work.
A common source of confusion is the combination of an async FastAPI endpoint and a synchronous test. The endpoint being async def does not require every test to be async. TestClient is designed for normal test functions and normal client calls.
Use FastAPI’s async testing pattern when the test itself must await something outside the request, such as an async repository or database operation. That path uses an async ASGI client instead of placing TestClient inside an async test.
Do I need to start Uvicorn?
No. These tests import the application and drive it in-process. A separately running server is appropriate for an end-to-end test against a deployed environment, but it adds process and network failure modes that this suite does not need.
Is this a unit test?
Not in the narrow sense. The test crosses several application layers through the HTTP interface. Calling it an API test or application-level integration test describes the boundary more clearly. The important point is what it proves, not the label.
What are pytest fixtures, and when should TestClient become one?
A module-level client is enough for this stateless example. Move it into a fixture when tests need shared setup, lifespan handling, dependency overrides, or reliable cleanup:
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
with TestClient(app) as test_client:
yield test_client
Tests using that fixture accept client as a function argument. Keep its scope as narrow as the state requires. A session-scoped client can be faster, but shared mutable state can make test results depend on execution order.
How do I set up a test database for FastAPI?
Put database session creation behind a FastAPI dependency, then replace that dependency in a pytest fixture. The fixture should create a disposable database or transaction, register the test session in app.dependency_overrides, yield the client, and always roll back or close its state during cleanup.
Each test should begin with known data. Never point automated tests at a production database. When database-specific SQL or transaction behavior matters, use the same database engine as production rather than relying on SQLite to behave identically.
How can I mock external services in FastAPI tests?
Put the outbound payment, email, or shipping call behind an injected dependency or small service interface. In the test, replace it with a deterministic fake through app.dependency_overrides. The fake can return a controlled result and record the input it received, while the test continues to assert the endpoint’s HTTP contract.
Use monkeypatch or unittest.mock when dependency injection is not practical, but patch the name used by your application rather than the library’s original definition. Normal API tests should not depend on a real third-party service or its network availability.
How do I test authentication with TestClient?
When authentication itself is the behavior under test, send the same authorization header or cookie as a real client and cover successful, unauthenticated (401), and unauthorized (403) responses. When you are testing business behavior behind authentication, override the get_current_user dependency with a known test user so token decoding does not distract from the endpoint contract.
Clear dependency overrides after each test or fixture. Leaked overrides create order-dependent tests and can make an authenticated request pass for the wrong reason.
Avoid the common shortcuts
- Do not stop after one happy-path
200response. - Do not call real payment, email, or model APIs from a normal test run.
- Do not assert private helper calls when the response contract is what matters.
- Do not share database or application state without an explicit cleanup strategy.
- Do not switch to async testing only because the endpoint uses
async def.
07 / NEXT STEP
Grow the suite when a real dependency gives you a reason.
The finished suite protects three distinct risks:
- a valid request returns the expected HTTP and JSON contract;
- values around the free-shipping threshold produce the correct cost;
- missing and negative totals are rejected before business logic runs.
That is enough for a first useful FastAPI API testing loop. The next complexity should come from the application, not from a tutorial checklist.
When the endpoint depends on a database, authentication provider, clock, or external API, introduce the matching pytest tool: fixtures for lifecycle, dependency overrides or fakes for isolation, parametrization for repeated contracts, and CI for automatic execution.
Continue with the complete Pytest course to learn fixtures, mocking, FastAPI dependencies, coverage, and automated testing with GitHub Actions.
View courseIf you are introducing those practices across a codebase rather than one endpoint, learn how to make pytest part of a team’s daily engineering workflow.
For one final check, temporarily rename the response field from shipping_cost to cost in main.py and rerun the suite. The request still succeeds, but the contract assertion fails. Restore the field and run the tests again. That short experiment shows why a good API test checks more than the status code.
More field notes
Keep reading.
Pytest for Beginners: Write and Run Your First Python Test
If you know how to write a Python function, you know enough to start testing. Build, run, and debug your first pytest test without creating a package.
pytestPython testingHow to Onboard a Python Team to Testing with Pytest
Learn how a 15-developer Python team used shared practice, legacy-code pinch points, and CI to turn pytest into an everyday engineering habit.
pytestPython testing