Follow along with all the code here

Introduction

If you’re a backend developer working with FastAPI, you already know the framework excels at simplifying API development with features like async support and automated Swagger docs. However, when it comes to integration testing–particularly mocking external services like MongoDB, AWS S3, and third-party APIs—the waters can get murky. This post is your guide to navigating these complexities.

This isn’t a one-stop-shop for all things testing; the focus is squarely on integration testing within FastAPI.

Prerequisites: Familiarity with FastAPI, PyTest, MongoDB, and AWS S3 is assumed. If you’re new to any of these technologies, you may want to get up to speed before proceeding.

What is Integration Testing?

Integration testing involves combining individual units of code and testing them as a group. This type of testing aims to expose faults in the interactions between integrated units. In our context, we could also probably call these API tests (and you’ll see that’s how I name my test files). In the context of FastAPI, these units often involve your API endpoints, external databases like MongoDB, and other services such as AWS S3.

Why It’s Challenging

When compared to unit tests, integration tests in FastAPI present unique difficulties. These challenges mostly stem from the interaction with external dependencies. Mocking these dependencies is not always straightforward, and mistakes can lead to false positives or negatives, undermining the purpose of the tests.

Why It Matters

So, why focus on integration testing in FastAPI? Because it’s here that you validate that various services and databases work in harmony. By ensuring that these integrated units function as expected, you not only increase code reliability but also save debugging time in the long run.

Unit tests are great for testing individual units of code, but they don’t test the interactions between these units. I also find integration tests (especially this kind, which test our endpoints) are particularly useful before doing a large refactor–going from a v0 to v1 of an app, where unit tests might not make a ton of sense because you’re rewriting all of your ‘units’.

A Glimpse into Our Test App

Before we delve into the technicalities of mocking various elements, let’s take a quick look at the FastAPI application we’ll be using as a test subject. This app serves as a sandbox for our integration tests, built to showcase different aspects of FastAPI that commonly require mocking in a test environment.

Our sample application has a straightforward schema designed for demonstration purposes:

 1/login  # simple login
 2  POST: Login
 3
 4/users/me/  # example for mocking auth
 5  GET: Read Users Me
 6
 7/users/me/preferences  # example for mocking mongodB
 8  GET: Get Preferences
 9  POST: Save Preferences
10
11/users/me/profile_pic  # example for mocking s3
12  GET: Get Profile Picture
13  POST: Save Profile Picture
14
15/weather/{city}  # example for mocking external API
16  GET: Get Weather

Our tests live in tests/ where they are organized according to the application components they evaluate. We employ PyTest for test execution and adhere to its conventions for discovering tests and initial set up. This includes utilizing the conftest.py configuration file for shared hooks and fixtures.

Mocking Authentication in FastAPI

As we move through the article, we’ll see that FastAPI provides an easy way to do dependency injection, which is especially useful for our testing scenarios. Using dependency_overrides, we can inject our mocked functions into the FastAPI app, but remember, this only works for endpoints/functions using the Depends() syntax. You can read more about dependency injection in FastAPI here.

In our FastAPI application, we have endpoints that require authentication. During testing, we don’t want to use real authentication because it would require us to manage real user credentials and tokens. This would complicate our tests and potentially expose sensitive information. Therefore, we mock the authentication process.

Testing Our Login

Before we get to dependency injection or mocking out our services, let’s make sure our auth works. We have simple authentication defined in the project that checks if a user is in the database, if their password matches, and returns a token if they are. (I am not going to focus on how to do authentication, but you can check out the code in auth.py if you’re interested.)

For the purposes of this tutorial, our database is just a dictionary with one hard coded user.

(I’ll be using my name as the username in this tutorial in order to burn it into your brain)
1# File: app/db.py
2users_db: Dict[str, UserInDB] = {
3    "alexjacobs": UserInDB(
4        username="alexjacobs",
5        email="alex@example.com",
6        hashed_password=get_password_hash("secret"),
7    )
8}

and our login endpoint…

 1# File: app/main.py
 2@app.post("/login", response_model=Token)
 3async def login(_login: Login):
 4    user = authenticate_user(users_db, _login.username, _login.password)
 5    if not user:
 6        raise HTTPException(
 7            status_code=status.HTTP_401_UNAUTHORIZED,
 8            detail="Incorrect username or password",
 9            headers={"WWW-Authenticate": "Bearer"},
10        )
11    access_token = create_access_token(
12        data={"sub": user.username}
13    )
14    return {"jwt": access_token, "token_type": "bearer"}

and we have some simple tests just to verify that our login endpoint works as expected

 1# File: tests/test_auth_api.py
 2def test_login(client):
 3    response = client.post("/login", json={"username": "alexjacobs", "password": "secret"})
 4    assert response.status_code == 200
 5    assert "jwt" in response.json()
 6
 7
 8def test_login_fail(client):
 9    response = client.post("/login", json={"username": "alexjacobs", "password": "not_the_right_password"})
10    assert response.status_code == 401
11    assert response.json() == {'detail': 'Incorrect username or password'}

These initial tests don’t require mocking the authentication process because they are designed to test the endpoint’s basic functionality. They act as a foundational layer upon which we will build more complex test scenarios that require mocking.

Mocking Authentication in Our Test Client

Now, let’s say we have another endpoint that requires authentication. We’ll use the Depends function to inject our authentication function into our endpoint. Our /users/me endpoint returns information about the user logged in.

1# File: app/main.py
2@app.get("/users/me/", response_model=User)
3async def read_users_me(_user: TokenData = Depends(get_auth)):
4    user = users_db.get(_user.username)
5    if user is None:
6        raise HTTPException(status_code=404, detail="User not found")
7    return user

Authentication is handled by the get_auth function, which is injected into our endpoint using the Depends function.
Since we don’t want to use real authentication in our tests, we’re going to mock this function. I’m going to show multiple ways of doing this so you can choose the one that works best for you.

The most standard way of mocking this function is to use the dependency_overrides feature of FastAPI, before we initialize our TestClient.

 1# File: tests/conftest.py
 2@pytest.fixture
 3def client(mock_s3_bucket):
 4    # we patch auth within our client fixture
 5    from app.main import app
 6
 7    def mock_get_auth():
 8        return TokenData(username="alexjacobs")
 9
10    app.dependency_overrides[get_auth] = mock_get_auth
11    with TestClient(app) as test_client:
12        yield test_client
13
14    app.dependency_overrides.clear()

You’ll see we declare a function called mock_get_auth that returns a TokenData object. Then in line 10, app.dependency_overrides[get_auth] = mock_get_auth, we ‘inject’ our mock function into our app in place of the get_auth function.

This code is essentially replacing the get_auth function in our app with a mock function that returns the TokenData object hardcoded into the function we’re pathing with.

Then, in our test we pass in the client fixture, and we can see that our test passes.

1# File: tests/test_auth_api.py
2def test_get_me_patched_auth(client):
3    # auth works without real jwt because we patched it in the client fixture
4    response = client.get("/users/me", headers={"Authorization": "Bearer " + 'fake.jwt'})
5    assert response.status_code == 200
6    assert response.json() == {'username': 'alexjacobs', 'email': 'alex@example.com'}

(we really don’t even need to include auth headers, since we’re mocking the auth function, but I’m keeping it here for clarity)

Mocking Authentication Directly in your Tests

Now, what if we want to mock the auth function in a different way? We can do this by mocking the auth function directly in our test.

First, we’ll create a second client fixture that doesn’t patch the auth function.

1# File: tests/conftest.py
2@pytest.fixture
3def client_unpatched_auth():
4    # we don't patch auth for this client
5    from app.main import app
6
7    with TestClient(app) as test_client_2:
8        yield test_client_2

Now we’re going to write a test to verify that auth fails (since we’re not patching the auth function)

1# File: tests/test_auth_api.py
2def test_get_me_unpatched_auth(client_unpatched_auth):
3    # auth fails without real jwt because we're using the unpatched client fixture
4    response = client_unpatched_auth.get("/users/me", headers={"Authorization": "Bearer " + 'fake.jwt'})
5    assert response.status_code == 401
6    assert response.json() == {"detail": "Could not validate credentials"}

To make this work, there are two approaches. The first is to mock the auth function directly in the test

 1# File: tests/test_auth_api.py
 2def test_get_me_path_auth_in_fn(client_unpatched_auth):
 3    # auth works because we patch it in this test (not in the client fixture)
 4    def mock_get_auth():
 5        return TokenData(username="alexjacobs")
 6
 7    app.dependency_overrides[get_auth] = mock_get_auth
 8
 9    response = client_unpatched_auth.get("/users/me", headers={"Authorization": "Bearer " + 'fake.jwt'})
10    assert response.status_code == 200

All we’ve done here is moved the code that patches the auth function into the test itself. This could be useful if you wanted to mock the auth function in some tests, but not others (but only wanted to have one client fixture).

Next, we’ll do the same thing, but we’re going to use a new fixture and factory function to create our mock auth function. So first, we’ll create a new fixture,

1# File: tests/conftest.py
2@pytest.fixture
3def mock_get_auth_factory():
4    def mock_get_auth():
5        return TokenData(username="alexjacobs")
6
7    return mock_get_auth

and then we’ll use this fixture in our test

1# File: tests/test_auth_api.py
2def test_get_me_patch_auth_with_fixture(client_unpatched_auth, mock_get_auth_factory):
3    # auth works because we patch it with a fixture instead of in the client fixture
4    app.dependency_overrides[get_auth] = mock_get_auth_factory
5    response = client_unpatched_auth.get("/users/me", headers={"Authorization": "Bearer " + 'fake.jwt'})
6    assert response.status_code == 200

Notice that when we’re passing in the mock_get_auth_factory fixture, we’re passing in the function itself, not the return value of the function. This is because the fixture is a factory function that returns a function, so we need to pass in the function itself.

This may not seem very useful (and for mocking auth, it may not be), but there are plenty of scenarios when you may want to mock a function in some tests, but not others. This is a good way to do that.

Mocking External APIs

Our FastAPI application makes external API calls to fetch weather data. During testing, we don’t want to make real API calls because they can be slow and unreliable. Therefore, we mock the external API calls.

We mock the external API calls by patching the fetch_weather function in our FastAPI application. This function is responsible for making the actual API call and returning the weather data. We replace it with a mock function that always returns a predefined weather data. But, this function does use dependency injection (the Depends funciton) in our app, so we have to mock it in a different way.

First, let’s look at our endpoint and the function it calls.

1# File: app/main.py
2@app.get("/weather/{city}", response_model=WeatherResponse)
3async def get_weather(city: str, _user: TokenData = Depends(get_auth)):
4    weather = fetch_weather(city)
5    return WeatherResponse(city=city, weather=weather)

and our fetch_weather() function

1# File: app/helpers.py
2def fetch_weather(city_name):
3    url = f"http://wttr.in/{city_name}?format=%C+%t"
4    response = requests.get(url)
5    return response.text

Pretty simple… we are hitting an external API, though, so we need to mock this out in our tests.

We have three ways to mock the external API calls:

Mocking External APIs with unittest.mock.patch Directly in Our Test

Using unittest.mock.patch, we patch our fetch_weather function to return ‘sunny’. We then verify our endpoint returns the expected response (and verify that our mock function was called–this last part probably isn’t really necessary here, but is a good demonstration)

1# File: tests/test_weather_api.py
2def test_get_weather(client):
3    with patch('app.main.fetch_weather', return_value='sunny') as mock_fetch_weather:
4        response = client.get("/weather/atlanta")
5        assert response.status_code == 200
6        assert response.json() == {'city': 'atlanta', 'weather': 'sunny'}
7        mock_fetch_weather.assert_called_once_with('atlanta')

Creating a fixture that patches the function

Let’s suggest that we may want to test multiple endpoints that call the fetch_weather function. We could patch the function in each test, but that’s a lot of code duplication. Instead, we can create a fixture that patches the function. First, we’ll set up our fixture (this is another factor function). We’re using unittest’s Mock and Patch functions in order to replace the fetch_weather function with a mock function that returns ‘rainy’.

Note: As before, we’re using a factory function to create our mock function, so we need to pass in the function itself, not the return value of the function.

1# File: tests/conftest.py
2@pytest.fixture
3def mock_fetch_weather_factory():
4    def mock_fetch_weather(city):
5        return "rainy"
6
7    mock = Mock(side_effect=mock_fetch_weather)
8    with patch.object(app.main, 'fetch_weather', new=mock):
9        yield

In our test, we just need to again pass in the fixture as an argument.

1# File: tests/test_weather_api.py
2def test_get_weather1(client, mock_fetch_weather_factory):
3    response = client.get("/weather/atlanta")
4    assert response.status_code == 200
5    assert response.json() == {'city': 'atlanta', 'weather': 'rainy'}

Creating a parametrized fixture

The above example probably isn’t very useful in practice. In the real world, if we’re testing multiple endpoints that call the same function, we probably want to test different scenarios. For example, we may want to test that our endpoint returns the correct response for different weather conditions. We could do this by creating multiple fixtures that patch the function with different mock functions, but this is a lot of code duplication and basically defeats the purpose of using a fixture at all. Instead, we can use a parametrized fixture to pass in values to our fixture to make it behave differently depending on our test.

To do this, we’re going to create another fixture, but this one will take a parameter. It essentially wraps our previous factory function, but now we can pass in a parameter to the fixture.

 1# File: tests/conftest.py
 2@pytest.fixture
 3def mock_fetch_weather_parametrized(request):
 4    def mock_fetch_weather_factory(weather):
 5        def mock_fetch_weather(*args, **kwargs):
 6            return weather
 7
 8        return mock_fetch_weather
 9
10    mock_weather = request.param
11    with patch.object(app.main, 'fetch_weather', new=mock_fetch_weather_factory(mock_weather)):
12        yield

And then, when we call our test, we need to decorate it with pytest.mark.parametrize, and pass in the fixture as an argument. I’ve set up two tests here so we can really see how it works.

 1# File: tests/test_weather_api.py
 2@pytest.mark.parametrize('mock_fetch_weather_parametrized', ['cloudy'], indirect=True)
 3def test_get_weather_parameterized(client, mock_fetch_weather_parametrized):
 4    response = client.get("/weather/atlanta")
 5    assert response.status_code == 200
 6    assert response.json() == {'city': 'atlanta', 'weather': 'cloudy'}
 7
 8
 9@pytest.mark.parametrize('mock_fetch_weather_parametrized', ['tornados'], indirect=True)
10def test_get_weather_parameterized(client, mock_fetch_weather_parametrized):
11    response = client.get("/weather/atlanta")
12    assert response.status_code == 200
13    assert response.json() == {'city': 'atlanta', 'weather': 'tornados'}

The parameterized fixture is more complicated to set up, but it is incredibly useful in practice when you have to simulate different responses from external APIs.

Mocking MongoDB

Our FastAPI application interacts with MongoDB. During testing, we might not want to (or really be able to in some cases) hit a real database with real/mocked data. Instead, we acn mock MongoDB by using the mongomock library, which simulates a MongoDB client. (Note: In my experience, mongomock can be difficult to work with and some practice to get working, but were going to proceed with it for now)

Creating a Mock MongoDB Client

In our application, we employ context management for database interactions, utilizing Python’s with statement. This demands that the object used within the with statement must implement context management protocols, specifically __enter__ and __exit__ methods.

 1# File: app/db.py
 2@contextmanager
 3def get_mongodb():
 4    try:
 5        with MongoClient() as client:
 6            db = client["preferences"]
 7            yield db
 8    except Exception as e:
 9        print(f'Error: {e}')
10        raise

The mongomock library doesn’t implement these methods, so to align with these requirements, we define a custom MockMongoClient class to wrap our mongomock client. This class mimics the behavior of the actual MongoClient by implementing the __enter__ and __exit__ methods. This ensures compatibility with the existing code that expects a context-managed database client.

 1# File: tests/conftest.py
 2class MockMongoClient:
 3    def __init__(self, db):
 4        self.db = db
 5
 6    def __enter__(self):
 7        return self.db
 8
 9    def __exit__(self, *args):
10        pass

Using our MockMongoClient class, we can now create our mock database client. We’ll do this in a fixture so we can reuse it in multiple tests.

Creating a Mock MongoDB Client Fixtures

I’m going to initialize two separate fixtures here, one with an empty database and one with some data initialized. (In theory, we should be able to set the scope of the fixture to session or module and just initialize the database once, add data through other tests, and then use that data for testing later, but I haven’t been able to get that working with mongomock. The project seems to have been designed more with unit tests in mind and is not a complete implementation or drop in replacement. If you know how to make this work, please let me know!)

1# File: tests/conftest.py
2@pytest.fixture
3def mock_mongodb():
4    def mock_get_mongodb():
5        mock_client = MongoClient()
6        return MockMongoClient(mock_client.db)
7
8    return mock_get_mongodb

and initialize some data…

1# File: tests/conftest.py
2@pytest.fixture
3def mock_mongodb_initialized():
4    def mock_get_mongodb():
5        mock_client = MongoClient()
6        mock_client.db.preferences.insert_one({"username": "alexjacobs", "city": "Berlin"})
7        return MockMongoClient(mock_client.db)
8
9    return mock_get_mongodb

And now we can write our tests. We’ll pass in our fixture and again use the dependency_overrides feature to inject our mock database client into our app (overriding the get_mongodb function)

Writing Tests Integration Tests Using Our Mocked MongoDB Client

We’ve got three simple tests here.
First, we test that our endpoint correctly returns a 404 if no user preferences exist (we use our non-initialized mock_mongodb fixture for this)

Next, we use our initialized mock_mongodb fixture to test that our endpoint correctly returns the user preferences.

Finally, we test that we can save user preferences (we use our non-initialized mock_mongodb fixture for this, but it should work with either)

 1# File: tests/user_preference_api.py
 2from app.main import app
 3from app.fake_db import get_mongodb
 4
 5
 6def test_get_user_preferences_404(client, mock_mongodb):
 7    # should return 404 since no user preferences exist
 8    app.dependency_overrides[get_mongodb] = mock_mongodb
 9    response = client.get("/users/me/preferences")
10    assert response.status_code == 404
11    assert response.json() == {'detail': 'Preferences not found'}
12
13
14def test_get_user_preferences_initialized(client, mock_mongodb_initialized):
15    # we're using our mock_mongodb_initialized fixture here which has our user preferences already set
16    app.dependency_overrides[get_mongodb] = mock_mongodb_initialized
17    response = client.get("/users/me/preferences")
18    assert response.status_code == 200
19    assert response.json() == {'city': 'Berlin'}
20
21
22def test_post_user_preferences(client, mock_mongodb):
23    app.dependency_overrides[get_mongodb] = mock_mongodb
24    response = client.post("/users/me/preferences", json={"city": "Berlin"})
25    assert response.status_code == 200
26    assert response.json() == {"detail": "success"}

And that’s it! Pretty simple to write the actual tests once we have our mocked database client set up correctly.

Mocking AWS S3

Our FastAPI application interacts with AWS S3 for storing and retrieving user profile pictures. During testing, we don’t want to use a real S3 bucket because it would require us to manage real AWS resources. This would complicate our tests and potentially incur costs. Therefore, we mock the S3 bucket. (The same patterns used here could be applied to other AWS services)

Creating the Mock S3 Fixture

We mock the S3 bucket by using the mock_s3 decorator from the moto library, which simulates an S3 bucket. This is done in a fixture so we can reuse it in multiple tests.

(moto is a great library for mocking AWS services, and you’ll see we are able to set our fixture scope to session, allowing us to maintain bucket state across multiple tests)

 1# File: tests/conftest.py
 2@pytest.fixture(scope="session")
 3def mock_s3_bucket():
 4    with mock_s3():
 5        conn = boto3.resource('s3', region_name='us-east-1')
 6        conn.create_bucket(Bucket=bucket)
 7        # we could upload a test file(s) here if we wanted to
 8        # s3_client = boto3.client('s3', region_name='us-east-1')
 9        # s3_client.upload_file('tests/assets/duck.png', bucket, 'profile_pics/alexjacobs.png') 
10        yield

Integrating with Test Client

This looks similar to our MockMongo fixtures, but this time, rather than pass our fixture into our tests, we pass it into the client fixture.

 1# File: tests/conftest.py
 2@pytest.fixture
 3def client(mock_s3_bucket):
 4    # we patch auth within our client fixture
 5    from app.main import app
 6
 7    def mock_get_auth():
 8        return TokenData(username="alexjacobs")
 9
10    app.dependency_overrides[get_auth] = mock_get_auth
11    with TestClient(app) as test_client:
12        yield test_client
13
14    app.dependency_overrides.clear()

Writing the Tests

Now we can write our tests. We’ll start by testing that we get the right message if no profile picture exists for the user

1# File: tests / test_user_preference_api.py
2def test_get_user_profile_pic_404(client):
3    response = client.get("/users/me/profile_pic")
4    assert response.status_code == 404
5    assert response.json() == {'detail': 'No profile pic found'}

Now we’ll test adding one…

1# File: tests/test_user_preference_api.py
2def test_set_user_profile_pic(client):
3    with open('tests/assets/duck.png', 'rb') as f:
4        response = client.post("/users/me/profile_pic", files={"picture": ("duck.png", f, "image/png")})
5
6    assert response.status_code == 200
7    assert response.json() == {'detail': 'success'}

Finally, we can test getting one. (notice how this is using the same file we uploaded in the previous test, this is due to the fact that we’re using the session scope for our mock_s3_bucket fixture)

1# File: tests/test_user_preference_api.py
2def test_get_user_profile_pic(client):
3    response = client.get("/users/me/profile_pic")
4    assert response.status_code == 200
5    with open('tests/assets/duck.png', 'rb') as f:
6        original_image_data = f.read()
7        original_base64 = base64.b64encode(original_image_data).decode('utf-8')
8
9    assert response.json()['image'] == original_base64

Wrapping Up

This was a fairly deep dive. We’ve unraveled the intricacies of integration testing in FastAPI, which is not without its challenges when it comes to mocking external dependencies. We’ve gone through a variety of techniques to mock authentication, from simple dependency_overrides to more advanced fixture-based strategies. We’ve also tackled how to mock external APIs using Python’s unittest.mock.patch and pytest’s parametrized fixtures.

When it comes to databases, MongoDB adds another layer of complexity. We’ve seen how Mongomock can be a useful tool, albeit with its own set of limitations. We crafted custom mock MongoDB clients and fixtures to ease this pain point. As for AWS S3, the Moto library proved to be a robust tool, enabling us to mock S3 buckets effectively, even allowing state persistence across tests.

The aim has been to arm you with a set of tools and strategies for your FastAPI testing arsenal. Whether it’s a simple authentication mock or a more complex external service, you should now be equipped to tackle these head-on. Happy testing.

Happy testing.