Skip to main content

Welcome to a Grasp lesson

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

Lesson 49

Testing File Upload Endpoints

Recommended resources

Hello! Welcome to the next lesson in our FastAPI course.

In the previous lesson, we learned how to test protected endpoints by mocking authentication dependencies. This powerful technique of using app.dependency_overrides allows us to isolate our endpoint's logic from the authentication system, leading to faster and more reliable tests.

Today, we'll tackle another common and critical testing scenario, which is especially relevant to your goal of serving AI models: testing endpoints that handle file uploads.

Introduction

This lesson covers how to write tests for endpoints that accept file uploads. In many real-world applications, particularly in the AI/ML space, APIs need to process user-submitted data like images, audio files, or large text documents. Ensuring this functionality is robust and secure is essential.

We will learn how to use FastAPI's TestClient to simulate an HTTP POST request containing a file. By the end of this lesson, you will be able to:

  • Construct a multipart/form-data request using TestClient.
  • Test an endpoint using both a physical file from your computer and a file created in-memory.
  • Assert that the file is processed correctly and that validation rules (like file type) are enforced.

This skill is a direct prerequisite for building and testing reliable ML inference APIs.


1. How to Test File Uploads with TestClient

When a browser or another client uploads a file, it typically uses a Content-Type of multipart/form-data. FastAPI's TestClient (which is based on httpx) provides a simple way to simulate this by using the files argument in its request methods (e.g., client.post()).

The files argument takes a dictionary. The key of the dictionary corresponds to the name of the file input in the form (e.g., the name attribute of <input type="file" name="my_file">). The value is a tuple containing the file's information.

The structure of this tuple is key: (filename, file_object, content_type)

  • filename: A string representing the name of the file (e.g., "image.png").
  • file_object: A file-like object opened in binary mode ('rb'). This can be a real file on your disk or an in-memory binary object.
  • content_type: A string representing the MIME type of the file (e.g., "image/jpeg", "application/pdf").

Let's see this in action with an example directly related to your goal of deploying AI models.


2. Testing an ML Endpoint with a Real Image File

For an ML model that performs image classification, you will have an endpoint that accepts an image. Your tests should verify that this endpoint can receive an image, process it, and return a prediction. Using a real test image from your file system is the most direct way to do this.

The following article demonstrates this perfectly.

Deploying a Vision Transformer Deep Learning Model with FastAPI ...

Please read the following section from the article 'Deploying a Vision Transformer...'. It provides a clear, practical example of testing a file upload endpoint for an ML model.

Please read the section titled 'Testing main.py'. Focus on the test_infer function. Pay close attention to how TestClient is used with open() to send a real image file to the /infer endpoint and how the response is validated.

As you saw in the article, the core of the test looks like this:




# Assuming 'client' is a TestClient instance and TEST_IMAGE_PATH is a path to an image
def test_infer():
    with open(TEST_IMAGE_PATH, "rb") as f:
        response = client.post(
            "/infer",
            files={"image": ("test_image.png", f, "image/png")}
        )
    
    assert response.status_code == 200



    # Further assertions on the prediction in the response JSON
    assert "probabilities" in response.json()

Let's break this down:

  1. with open(TEST_IMAGE_PATH, "rb") as f:: We open a test image file from the disk in binary read mode ("rb"). The with statement ensures the file is automatically closed after the block, which is best practice.
  2. files={"image": ...}: We tell TestClient that the form field is named "image". This must match the name expected by your UploadFile parameter in the endpoint (e.g., async def infer(image: UploadFile)).
  3. ("test_image.png", f, "image/png"): This is our tuple.
    • "test_image.png" is the filename sent to the server.
    • f is the file object from open().
    • "image/png" is the content type, which your endpoint might use for validation.

This approach is ideal for integration tests where you want to use realistic test data.


3. Testing with In-Memory Files and Validating Input

Sometimes, you may not want your tests to depend on files on your disk. For more isolated unit tests, you can create a "fake" file directly in memory using Python's io module. This is also a great way to test how your endpoint handles different types of file content or edge cases without creating lots of physical test files.

The io.BytesIO object behaves like a binary file but exists only in memory.

How to Implement File Uploads in FastAPI

The following article gives excellent examples of how to use in-memory files for testing and how to test validation logic.

Please read the section 'Testing File Uploads'. Observe how BytesIO is used in test_upload_valid_file to create a file in memory. Also, look at test_upload_invalid_extension and test_upload_too_large to see how you can test your endpoint's validation rules.

Creating an In-Memory File

From the article, you can see the pattern for using io.BytesIO:




# test_uploads.py
from io import BytesIO
from fastapi.testclient import TestClient
from app import app

client = TestClient(app)

def test_upload_valid_file():



    # Some raw binary data representing a tiny PNG image
    png_data = (
        b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
        b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00'
        b'\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00'
        b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
    )
    
    response = client.post(
        "/api/upload",
        files={"file": ("test.png", BytesIO(png_data), "image/png")}
    )
    
    assert response.status_code == 200
    assert response.json()["original_name"] == "test.png"

Here, BytesIO(png_data) creates the in-memory binary file object, which is then used in the file tuple just like the file object from open().

Testing Failure Cases

Your API should be resilient. What if a user uploads a .txt file instead of a .png? Or a file that is too large? Your tests should cover these scenarios. The oneuptime.com resource shows this well:

  • Invalid File Type: Send a file with a non-allowed content type (e.g., application/octet-stream) and assert that your application returns a 400 Bad Request status code.
  • File Too Large: Create a large in-memory file (BytesIO(b"x" * 10_000_000)) and assert that the application correctly returns a 413 Payload Too Large status code (or 400 if you handle it that way).

4. Combining with Authentication

What if your file upload endpoint is protected? You can simply combine the techniques from the last lesson with what you've learned today. Use your authenticated_client fixture that overrides the auth dependency, and then make the file upload request with it.

def test_upload_avatar_for_authenticated_user(authenticated_client):



    # authenticated_client is the fixture from our last lesson
    with open("path/to/avatar.jpg", "rb") as f:
        response = authenticated_client.post(
            "/users/me/avatar",
            files={"avatar": ("my_avatar.jpg", f, "image/jpeg")}
        )

    assert response.status_code == 200
    assert response.json()["message"] == "Avatar updated successfully"

This shows how the testing skills you're building are composable, allowing you to test complex interactions cleanly.


5. Practical Exercise

Time to apply what you've learned. Imagine you have an endpoint POST /files/metadata that accepts any file and returns its metadata.

Endpoint Logic (for context):

@app.post("/files/metadata")
async def get_file_metadata(file: UploadFile):
    content = await file.read()
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size_bytes": len(content),
    }

Your Task:
Write a pytest test function named test_get_file_metadata_for_text_file. In this function:

  1. Create an in-memory text file containing the string "Hello, FastAPI!". Remember to encode the string to bytes (e.g., "Hello, FastAPI!".encode('utf-8')).
  2. Use TestClient to POST this in-memory file to the /files/metadata endpoint.
  3. The files dictionary key should be "file".
  4. The tuple should specify the filename as "hello.txt" and the content type as "text/plain".
  5. Assert that the response status code is 200.
  6. Assert that the returned JSON response is correct. It should match the filename, content type, and size you sent.
Click to see a possible solution



# tests/test_files.py
from fastapi.testclient import TestClient
from io import BytesIO
from main import app # Assuming your app is in main.py

client = TestClient(app)

def test_get_file_metadata_for_text_file():



    # 1. Create the in-memory file content
    file_content = b"Hello, FastAPI!"
    in_memory_file = BytesIO(file_content)




    # 2. Make the POST request
    response = client.post(
        "/files/metadata",
        files={"file": ("hello.txt", in_memory_file, "text/plain")}
    )




    # 3. Assert status code
    assert response.status_code == 200




    # 4. Assert response body
    expected_data = {
        "filename": "hello.txt",
        "content_type": "text/plain",
        "size_bytes": len(file_content), # Should be 15
    }
    assert response.json() == expected_data

Conclusion

Excellent work! You can now write comprehensive tests for one of the most common and important API features: file uploads.

Key Takeaways:

  • Use the files argument in TestClient to simulate file uploads: client.post("/upload", files=...).
  • The files dictionary value must be a tuple: (filename, file_object, content_type).
  • For integration tests with real data, use with open("path", "rb") to create the file_object.
  • For isolated unit tests, use io.BytesIO to create an in-memory file_object.
  • Always test edge cases, such as incorrect file types or files that are too large, to ensure your API is robust.

You've now completed the module on testing! In the next module, we will shift our focus from writing and testing code to packaging our application for deployment using Docker. This is a crucial step in preparing your FastAPI service to run in the cloud.