Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 46

Testing API Endpoints: Status Codes and Response Content

Recommended resources

Hello! In our last lesson, we successfully set up a complete testing environment using pytest and created a TestClient fixture. With that solid foundation in place, we're ready to move from setup to execution.

This lesson focuses on the core of API testing: verifying that your endpoints behave exactly as you expect. We'll learn how to write unit tests that send requests to your API, then check—or assert—that the HTTP status codes and the data in the response body are correct. This is the most fundamental skill for ensuring your API is reliable and bug-free.

By the end of this lesson, you will be able to confidently write unit tests for your endpoints, asserting both status codes and response content.

1. The Anatomy of an API Test

Most API tests follow a simple, predictable pattern often called "Arrange-Act-Assert":

  1. Arrange: Prepare everything for the test. In our case, the client fixture we created in the last lesson handles most of this. For some tests, we might also prepare input data, like a JSON payload.
  2. Act: Perform the action you want to test. This means using the TestClient to make an HTTP request to one of your endpoints (e.g., a GET or POST request).
  3. Assert: Check if the outcome was what you expected. This involves inspecting the response object returned by the client and asserting that its status_code and JSON content are correct.

Let's see this pattern in action.


2. Writing Your First Tests: GET Endpoints

We'll start with the simplest case: testing a GET endpoint. Imagine you have a file app/main.py with the following basic FastAPI application:




# app/main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello World"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "q": q}

Now, in your tests/test_main.py file, you can write tests for these endpoints. The following reading provides a clear guide on how to structure these tests.

Using FastAPI TestClient for Unit Tests

The article 'Using FastAPI TestClient for Unit Tests' provides excellent, concise examples for testing basic endpoints. Focus on how it uses the TestClient to make requests and assert to check the results.

First, read the section under 'Setting Up the TestClient' (starting from # test_main.py) to see how to test the root / endpoint. Then, read the section 'Testing Path and Query Parameters' to understand how to test an endpoint like /items/{item_id}.

Based on the examples in the reading, your tests/test_main.py would look like this:




# tests/test_main.py
# The 'client' fixture is automatically provided by pytest from conftest.py

def test_read_root(client):



    # 2. Act
    response = client.get("/")



    # 3. Assert
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

def test_read_item(client):
    item_id = 42



    # 2. Act
    response = client.get(f"/items/{item_id}")



    # 3. Assert
    assert response.status_code == 200
    assert response.json() == {"item_id": 42, "q": None}

def test_read_item_with_query_param(client):
    item_id = 42
    query = "test_query"



    # 2. Act
    response = client.get(f"/items/{item_id}", params={"q": query})



    # 3. Assert
    assert response.status_code == 200
    assert response.json() == {"item_id": 42, "q": "test_query"}

Notice a few key details:

  • Each test function accepts the client fixture as an argument.
  • Path parameters are included directly in the URL using an f-string: f"/items/{item_id}".
  • Query parameters are passed as a dictionary to the params argument.
  • We assert the status_code is 200 (OK) and the json() content matches our expectation.

3. Testing Endpoints with Request Bodies (POST)

Testing endpoints that accept data, like a POST request, is just as straightforward. This is particularly relevant to your goal of serving ML models, where you'll be posting input features for prediction.

Let's add a POST endpoint to our app/main.py:




# app/main.py (additions)
from pydantic import BaseModel

class Item(BaseModel):
    name: str
    price: float

@app.post("/items/")
def create_item(item: Item):
    return {"item_name": item.name, "item_price": item.price}

To test this, we'll use client.post() and pass our payload using the json argument.

The following video gives a great, fast-paced overview of testing GET, POST, and other methods. Pay close attention to how the POST request is constructed.

Python Unit Testing | FastAPI with Pytest Tutorial (fast & easy)

This clip from 'Python Unit Testing | FastAPI with Pytest Tutorial' by Eric Roby demonstrates testing a full suite of CRUD endpoints. Focus on the patterns for each HTTP method.

Watch from 04:07 to 06:44. This segment covers testing a GET endpoint and then a POST endpoint. Notice how the POST test includes a json payload and asserts the response content.


4. Testing for Expected Errors: Input Validation

A crucial part of building a robust API is ensuring it handles invalid input gracefully. FastAPI and Pydantic automatically handle validation, returning a 422 Unprocessable Entity status code when the request body doesn't match the Pydantic model. We must test for this behavior.

FastAPI Swagger UI: Endpoint Responses
This Swagger UI documentation for a POST endpoint clearly shows the expected successful response (201 Created or 200 OK) and the validation error response (422 Unprocessable Entity), which is exactly what our tests will verify.

Let's write two tests for our create_item endpoint: one for a successful creation (the "happy path") and one for a validation error.




# tests/test_main.py (continued)

def test_create_item_success(client):



    # 1. Arrange
    item_data = {"name": "Test Item", "price": 9.99}
    



    # 2. Act
    response = client.post("/items/", json=item_data)
    



    # 3. Assert
    assert response.status_code == 200
    assert response.json() == {"item_name": "Test Item", "item_price": 9.99}

def test_create_item_invalid_payload(client):



    # 1. Arrange - missing the 'price' field
    invalid_data = {"name": "Incomplete Item"}
    



    # 2. Act
    response = client.post("/items/", json=invalid_data)
    



    # 3. Assert
    assert response.status_code == 422

The second test confirms that our API correctly rejects malformed data, preventing it from ever reaching our business logic. As you can see, testing for errors is as simple as testing for success—you just change the expected status_code.

The codesignal.com resource provides further excellent examples of this, particularly how to use pytest.mark.parametrize to test multiple error conditions with a single test function.

Testing FastAPI Applications with pytest

This article from CodeSignal Learn, 'Testing FastAPI Applications with pytest', has a great section on verifying error handling.

Read the section 'Testing Error Handling Scenarios'. It demonstrates a powerful technique for testing multiple invalid inputs (missing field, wrong data type, invalid value) and asserting the 422 status code for each.


5. Practical Exercise

Now it's time to put it all together. Let's assume you have the following complete app/main.py:




# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Dict

app = FastAPI()




# A simple in-memory database
db: Dict[int, str] = {}
next_id = 0

class Message(BaseModel):
    content: str

@app.get("/")
def read_root():
    return {"status": "online"}

@app.post("/messages/", status_code=201)
def create_message(message: Message):
    global next_id
    db[next_id] = message.content
    created_message = {"id": next_id, "content": message.content}
    next_id += 1
    return created_message

@app.get("/messages/{message_id}")
def read_message(message_id: int):
    if message_id not in db:
        raise HTTPException(status_code=404, detail="Message not found")
    return {"id": message_id, "content": db[message_id]}

Your task is to create a tests/test_main.py file and write tests for the following scenarios:

  1. Test the root GET / endpoint. It should return a 200 status code and a JSON body {"status": "online"}.
  2. Test the POST /messages/ endpoint with a valid payload. It should return a 201 status code and the created message object.
  3. Test the POST /messages/ endpoint with an invalid payload (e.g., an empty JSON {}). It should return a 422 status code.
  4. Test the GET /messages/{message_id} endpoint for a message that exists. It should return 200 and the correct message. (Hint: you'll need to create a message first within the same test function).
  5. Test the GET /messages/{message_id} for a message that does not exist. It should return a 404 status code.

Take a few minutes to write these tests. Once you're done, you can compare your work against the solution below.

Click to see the solution



# tests/test_main.py

def test_read_root(client):
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"status": "online"}

def test_create_message_success(client):
    response = client.post("/messages/", json={"content": "hello test"})
    assert response.status_code == 201
    data = response.json()
    assert data["content"] == "hello test"
    assert "id" in data

def test_create_message_invalid_payload(client):
    response = client.post("/messages/", json={}) # Missing 'content'
    assert response.status_code == 422

def test_read_existing_message(client):



    # First, create a message to ensure it exists
    create_response = client.post("/messages/", json={"content": "find me"})
    assert create_response.status_code == 201
    message_id = create_response.json()["id"]




    # Now, try to read it
    read_response = client.get(f"/messages/{message_id}")
    assert read_response.status_code == 200
    assert read_response.json() == {"id": message_id, "content": "find me"}

def test_read_nonexistent_message(client):



    # Try to read a message ID that is unlikely to exist
    response = client.get("/messages/99999")
    assert response.status_code == 404

Conclusion

Congratulations! You've just learned the fundamental workflow for writing automated tests for a FastAPI application. This practice is the bedrock of building reliable, maintainable, and professional-grade APIs.

Key Takeaways:

  • API tests follow a simple Act-Assert pattern: make a request, then check the response.
  • The TestClient provides methods that mirror HTTP verbs: client.get(), client.post(), etc.
  • Path parameters go in the URL string, while query parameters are passed via the params dictionary.
  • Request bodies are sent using the json argument in methods like client.post().
  • You must test for both success cases ("happy paths") and expected error cases (like 422 validation errors or 404 not found errors).

In our next lesson, we will tackle a more advanced and powerful testing concept: overriding dependencies during testing to mock external services like databases. This will allow us to test our application logic in complete isolation, making our tests faster and even more reliable.