Skip to main content

Welcome to a Grasp lesson

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

Lesson 48

Testing Protected Endpoints with Mock Authentication

Recommended resources

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

In our previous lesson, we saw how to make our tests robust and isolated by overriding database dependencies. This allowed us to run tests against a temporary, in-memory database, which is a critical skill for professional development.

Today, we will apply that exact same powerful technique to another common challenge: security.

Introduction

This lesson focuses on how to write tests for protected endpoints by providing mock authentication credentials. Many endpoints in a real-world application are not public; they require a user to be logged in. Testing these endpoints naively would require a full login flow for every single test, making them slow and fragile.

We will build directly on the concept of app.dependency_overrides from the last lesson. Instead of swapping out a database connection, we will swap out the dependency that identifies the current user, allowing us to "fake" a login for any user we want during a test. This gives us complete control to test three crucial scenarios:

  1. How the endpoint behaves for an unauthenticated user.
  2. How it behaves for a correctly authenticated user.
  3. How it behaves for users with different permissions (e.g., a regular user vs. an admin).

1. The Challenge of Testing Protected Endpoints

Imagine you have a protected endpoint that returns the current user's profile information. It relies on a dependency, let's call it get_current_user, to decode a JWT from the Authorization header and fetch the user.




# A typical protected endpoint
@app.get("/users/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_user)):
    return current_user

To test this, you could write a test that:

  1. Calls the /login endpoint with a username and password.
  2. Receives a JWT access token.
  3. Calls the /users/me endpoint, placing the token in the Authorization: Bearer <token> header.
  4. Asserts the response is correct.

While this type of integration test has its place, it's not ideal for testing the logic of the /users/me endpoint itself. Why? Because the test now depends on the /login endpoint working correctly. If the login logic breaks, your /users/me test will also fail, even if the /users/me code is perfect. This makes debugging harder.

The better approach is to isolate the endpoint by mocking the authentication dependency.


2. The Solution: Overriding the Authentication Dependency

Just as we did with the database, we can use app.dependency_overrides to replace get_current_user with a simple function that returns a hardcoded user object. This completely bypasses the need for tokens, password hashing, and login flows, allowing us to focus solely on the endpoint's logic.

The following article provides a clear, step-by-step guide to this recommended approach.

Testing FastAPI Applications with Pytest and TestClient - Cursa

Please read the following sections from the article 'Testing FastAPI Applications with Pytest and TestClient'. They explain the core strategy of overriding the user dependency and provide excellent code examples.

Please read the sections titled 'Testing authentication: JWT-protected endpoints', 'Approach A (recommended for most route tests): override the current-user dependency', and the code under 'tests/test_auth.py' at the end of the article. Focus on how a mock user is defined and how app.dependency_overrides is used to test both successful and unauthorized access.

Let's break down the key patterns from the article.

Testing Unauthorized Access

First, you should always have a test that confirms your endpoint is actually protected. This is the simplest test: just call the endpoint without providing any authentication. You should expect a 401 Unauthorized or 403 Forbidden status code.




# tests/test_auth.py
def test_me_unauthorized(client):



    # No override is active, so this simulates a real unauthenticated request
    r = client.get("/me")
    assert r.status_code == 401 # Or 403, depending on your implementation

Testing Authorized Access

This is where the override happens. We define a simple function that returns a user dictionary and tell our app to use it instead of the real get_current_user dependency.




# tests/test_auth.py
from app.dependencies import get_current_user # The original dependency
from app.main import app # Your FastAPI app instance

def test_me_authorized(client):



    # 1. Define the mock user data
    mock_user = {"id": 1, "email": "test@example.com", "is_active": True}
    



    # 2. Define the override function
    def override_get_current_user():
        return mock_user




    # 3. Apply the override
    app.dependency_overrides[get_current_user] = override_get_current_user




    # 4. Make the request
    r = client.get("/me")




    # 5. Assert success and correct data
    assert r.status_code == 200
    assert r.json()["email"] == "test@example.com"




    # 6. Clean up the override!
    app.dependency_overrides.clear()

Important: It is crucial to clean up the override using app.dependency_overrides.clear() after the test to prevent it from affecting other tests. pytest fixtures, as we'll see next, handle this cleanup automatically.


3. Using Fixtures to Manage Auth Overrides

Applying and clearing overrides in every single test function can be repetitive. A much cleaner approach is to create pytest fixtures that provide pre-configured test clients. You could have:

  • A standard client fixture for unauthenticated requests.
  • An authenticated_client fixture that automatically applies the override for a standard user.

This pattern is demonstrated well in the resources. Let's look at another article that shows how to structure this within a conftest.py file.

Mastering Integration Testing with FastAPI | Alex Jacobs

The article 'Mastering Integration Testing with FastAPI' by Alex Jacobs shows several excellent patterns for mocking authentication. Pay attention to how a fixture is used to provide an authenticated client.

Read the section 'Mocking Authentication in Our Test Client'. Notice how the dependency_overrides logic is moved inside a client fixture. This is a very common and effective pattern.

By placing the override logic inside a fixture, your test functions become much cleaner and more focused on the specific behavior they are testing.




# tests/conftest.py
import pytest
from app.main import app
from app.dependencies import get_current_user

@pytest.fixture
def authenticated_client(client): # `client` is your basic TestClient fixture
    mock_user = {"id": 1, "email": "test@example.com"}
    def override_get_current_user():
        return mock_user

    app.dependency_overrides[get_current_user] = override_get_current_user
    yield client # The client is now "authenticated"
    app.dependency_overrides.clear() # Cleanup happens automatically after the test




# tests/test_auth.py
def test_me_with_fixture(authenticated_client):



    # No setup needed here! The fixture did it all.
    r = authenticated_client.get("/me")
    assert r.status_code == 200
    assert r.json()["email"] == "test@example.com"

4. Testing Authorization Rules (Roles and Permissions)

Mocking authentication isn't just for checking if a user is logged in; it's also the perfect tool for testing authorization logic (i.e., what a user is allowed to do).

Imagine you have an endpoint that should only be accessible to users with an "admin" role.




# An admin-only endpoint
@app.delete("/admin/users/{user_id}")
async def delete_user(user_id: int, current_user: User = Depends(get_current_admin_user)):



    # ... logic to delete a user ...
    return {"status": "ok"}

You can write specific tests for each authorization case by simply changing the user object returned by your override.

The Cursa article you read earlier has a great example of this.

Testing FastAPI Applications with Pytest and TestClient - Cursa

Let's revisit the 'Testing FastAPI Applications' article to see how to test for specific roles.

Read the section 'Testing authorization rules: roles and ownership'. This demonstrates how to test both 'forbidden' and 'allowed' cases by providing different mock users.

Based on that example, your tests would look like this:




# tests/test_admin.py




# Test case 1: A regular user is forbidden
def test_admin_delete_forbidden_for_user(client):
    def override_get_current_user_as_regular():
        return {"id": 2, "email": "user@example.com", "role": "user"}

    app.dependency_overrides[get_current_user] = override_get_current_user_as_regular
    
    r = client.delete("/admin/users/1")
    assert r.status_code == 403 # Forbidden
    
    app.dependency_overrides.clear()




# Test case 2: An admin is allowed
def test_admin_delete_allowed_for_admin(client):
    def override_get_current_user_as_admin():
        return {"id": 1, "email": "admin@example.com", "role": "admin"}

    app.dependency_overrides[get_current_user] = override_get_current_user_as_admin
    
    r = client.delete("/admin/users/1")



    # Assuming the user deletion logic works and returns 204 No Content
    assert r.status_code == 204
    
    app.dependency_overrides.clear()

This pattern is incredibly powerful. It allows you to write precise, readable tests for every permission rule in your application without the overhead of a real authentication system.


5. Practical Exercise

Now it's your turn to put this into practice. Imagine you have a notes application where users can only view their own notes.

Setup:

  • An endpoint: GET /notes/{note_id}
  • A dependency: get_current_user() which provides a user object like {"id": 123, "email": "user@example.com"}.
  • The endpoint logic fetches a note from the database. A note object looks like {"id": 1, "content": "My secret note", "owner_id": 123}.
  • The endpoint logic compares note.owner_id with current_user['id']. If they don't match, it raises an HTTPException with status code 403.

Your Task:

Write two test functions:

  1. test_read_own_note_success: This test should mock a user and request a note where the owner_id matches the mocked user's id. It should assert a 200 OK status code.
  2. test_read_other_users_note_forbidden: This test should mock a user and request a note where the owner_id does not match the mocked user's id. It should assert a 403 Forbidden status code.

You can assume you have a client fixture and that the database part is already mocked (e.g., your endpoint uses a fake dictionary lookup for the note). Focus only on the authentication override.

Click to see a possible solution



# tests/test_notes.py
from app.main import app
from app.dependencies import get_current_user




# Assume the endpoint looks something like this for context:
#
# FAKE_NOTES_DB = {
#     10: {"id": 10, "content": "Note for user 1", "owner_id": 1},
#     20: {"id": 20, "content": "Note for user 2", "owner_id": 2},
# }
#
# @app.get("/notes/{note_id}")
# async def get_note(note_id: int, current_user: dict = Depends(get_current_user)):
#     note = FAKE_NOTES_DB.get(note_id)
#     if not note:
#         raise HTTPException(status_code=404, detail="Note not found")
#     if note["owner_id"] != current_user["id"]:
#         raise HTTPException(status_code=403, detail="Not authorized to access this note")
#     return note

def test_read_own_note_success(client):



    # Mock user with ID 1
    def override_with_user_1():
        return {"id": 1, "email": "user1@example.com"}

    app.dependency_overrides[get_current_user] = override_with_user_1




    # Request a note owned by user 1
    response = client.get("/notes/10")

    assert response.status_code == 200
    assert response.json()["content"] == "Note for user 1"

    app.dependency_overrides.clear()


def test_read_other_users_note_forbidden(client):



    # Mock user with ID 1
    def override_with_user_1():
        return {"id": 1, "email": "user1@example.com"}

    app.dependency_overrides[get_current_user] = override_with_user_1




    # Request a note owned by user 2
    response = client.get("/notes/20")

    assert response.status_code == 403
    assert response.json()["detail"] == "Not authorized to access this note"

    app.dependency_overrides.clear()

Conclusion

Congratulations! You've now mastered another fundamental testing technique. By combining your knowledge of dependency overrides from the previous lesson with today's focus on authentication, you can now write comprehensive, isolated, and fast tests for even complex, secure applications.

Key Takeaways:

  • Isolate Your Tests: Avoid calling login endpoints in your tests. Instead, mock the authentication dependency to test endpoint logic in isolation.
  • Reuse Your Tools: The app.dependency_overrides mechanism is the same one used for mocking databases, demonstrating its versatility.
  • Test All Scenarios: Use overrides to easily test for unauthenticated access (no override), successful authenticated access, and complex authorization rules (different mocked users).
  • Keep Tests Clean: Use pytest fixtures to manage the setup and teardown of authentication overrides, reducing boilerplate code in your test functions.

In our next lesson, we will move on to another essential topic: how to test endpoints that handle file uploads. This will introduce new challenges related to testing I/O operations and multipart form data.