Testing in FastAPI with Pytest

Video thumbnail

Testing is a crucial part of any application we create, regardless of the technology. It's always advisable to run automated tests to test the system when new changes are implemented. This saves us a lot of time, as there's no need to run many of the tests manually, but rather to run a simple command.

Unit testing involves testing components individually. In the case of the application we built, this will be each of the API methods, as well as any other dependencies on these methods. This way, when these automated tests are run, if the application passes all the tests, it means no errors were found. However, if the tests don't pass, it means changes need to be made at the application level or unit tests implemented.

pytest, for unit testing

To perform unit testing, we will use the pytest library, which is a library for performing Python unit tests (and not exclusive to FastAPI).
 

Test user

We run this as a Python module. This would be the test_user module. There you go, it passed. Let's check the database and evaluate the rest. And yes, here it is: it works correctly.

The project we're going to use is the following:

https://github.com/libredesarrollo/curso-libro-fast-api-crud-task-1/releases/tag/v0.11

Considerations about headers and the client

As you can see, it's not necessary to pass the headers, as they're implicitly defined in the client. Since this is a FastAPI module, it knows it works with JSON requests, so there's no need to pass them. There's also no need to specify async or await; it does that automatically.

Finally, we make the POST request, register the user, and that's about it. This is where you put what you want to test. In my case, I copied and pasted what I already had to save time, but you can modify the tests a bit to your liking.

What you want to do with the tests is quite subjective; it's up to you how you want to test them. For example, we're checking the status code, which is always a good idea. We save the response in data to make it easier to manage.

If an email is returned, you can evaluate that as well. Currently, if we review the registration response, what it returns is a message, although you could also return the user to evaluate the result, including their ID. Since we didn't pass it, it's generated automatically, but you can check other fields.

And well, as I said, these are possible checks you can perform. With this, we complete the first unit test.

Testing login (token creation)

Let's now test the login method, that is, the creation of the token. To do this, we create a function, test_login_user, which will not return anything.

We define the payload, copy and paste the username. We remove everything else that is unnecessary.

Then we build the response with the token method, which is a POST type. In this case, since it's a request via form data, we can't use JSON. Instead, we use data=payload.

From here, we execute the asserts to evaluate the response: we verify that it's 200. We save the response as data = response.json() and check that it returns an access_token.

Currently, no token is created, so we test the previous method. There it is, the test passed. We check if the token was created: yes, there we have it.

Not much more to say. The only difference here is that, when passing data through form-data, we use data instead of JSON. Otherwise, it's exactly the same structure.

tests/test_user.py

from fastapi.testclient import TestClient

from database.database import get_database_session
from api import app

client = TestClient(app)
app.dependency_overrides[get_database_session] = get_database_session

def test_sign_new_user() -> None:

   payload = {
       'email': 'admintesttestclient@admin.com',
       'name': 'andres',
       'surname': 'cruz',
       'website': 'https://desarrollolibre.net/',
       'password': '12345'
   }

   response = client.post('/register', json=payload)

   assert response.status_code == 201
   assert response.json() == {
       "message": "User created succefully"
   }

   # assert response.status_code == 201
   # data = response.json()
   # assert data["email"] == "admintesttestclient@admin.com"
   # assert "id" in data

Testing the logout

Now let's move on to the logout function. You can treat this test as a little challenge. It's practically the same as what we did before, so go ahead.

I'm going to implement it anyway. We create test_logout, or whatever name you prefer. We indicate that it returns nothing.

Let me copy the body because it's practically the same. We add the headers and use client. It's important to define the headers here because we're going to add an additional field. We also include the familiar ones: Accept: application/json and Content-Type: application/json, even if we don't send anything.

We make the normal request and evaluate the response. Of course, the test requires a valid token, so we define it and save it.

You could even do an extra check, like we did when we deleted a task: check if it exists in the database. But in this case, make sure the token doesn't exist.

We've already done this before, so we won't repeat it here.

Finally, we call the test_logout function. It passed. We verify that the operation was completed, and that's it. With this, we complete the user tests.

tests\test_user.py

@pytest.mark.asyncio
async def test_logout(default_client: httpx.AsyncClient) -> None:
   headers = {
       'accept': 'application/json',
       'Token': 'WKXvHzLIOmQIpPwmuIvu_N9GY2Pb0f4qtQO9sf9Drrk',
       'Content-Type': 'application/json'
   }

   response = await default_client.delete('/logout', headers=headers)

   assert response.status_code == 200
   assert response.json()['msj'] == "ok"

Creating a task with form-data

Now let's move on to the task creation operation. This time we do it in another file: test_task.py.

We get the header—you could also place it in a global file and import it—and begin.

Just like before, you'll allow me to copy because it's practically the same. We create the signature: test_create_task, which returns None.

We pass the necessary data. No headers are needed, so we remove them. The request is straightforward.

We place test_create_task, execute it, and it passes. We check if the task was created. In my case, I had up to task 25, I reloaded it, and now I have task 26. Perfect!

Why we continue with unit testing
In this section, we continue working with unit testing. Why? As I've mentioned several times before, we use pytest, which, while powerful, isn't specifically designed for FastAPI. It's a module you can use to test anything in Python, even without frameworks.

But now we're going to use FastAPI's TestClient, which is designed for this purpose. As you can see in an excerpt from my book, we imported it directly from FastAPI, and it allows us to create tests very easily.

Before, we saw the hard way. Now, we'll see the easy way.

tests\test_task.py

import httpx
import pytest

@pytest.mark.asyncio
async def test_create_task(default_client: httpx.AsyncClient) -> None:
   payload = {
       'name': 'Tasks 1',
       'description': 'Description Task',
       'status': 'done',
       'user_id': '1',
       'category_id': '1',
   }

   headers = {
       'accept': 'application/json',
       'Content-Type': 'application/json'
   }

   response = await default_client.post('/tasks/', json=payload, headers=headers)

   assert response.status_code == 201
   assert response.json()['tasks']['name'] == payload['name']

Basic configuration with TestClient

All the configurations we made before, we can now achieve in five lines of code:

  • We import the module.
  • We import the database.
  • We import the app.
  • We create the client with TestClient.
  • We pass the app to it, and that's it.

Since everything is part of FastAPI, there's no need to add anything extra. app references the entire application, including routes and middleware.

Plus, you can override dependencies if necessary. This allows you to use other databases in your tests, for example. In fact, it's ideal: having one database for production, another for development, and another for testing.

Reviews and suggestions

Here you can put whatever you want to test. For example, to make it a little more interesting and change it a bit, I copied and pasted because that's what I did before, and to save time on that, I changed the tests a bit.

As I said, what you want to do with the tests is quite subjective; it's up to you how you want to test it and what you think is best.

For example, here we check the code again. I think that's always a good idea. We save the response in data so it's more manageable.

In case it returns the email, which I don't remember if that's what it's returning now... Well, we can look it up here. Here we have it: register. In this case, it's returning—because we changed it before—a message. But you could also return the user here and evaluate the result that way.

You can also verify that it has the ID. Obviously, we don't know the ID because we're not passing it. We are passing this, so it should match perfectly. I don't know what I was doing here, but as I said, it's another possible check you could perform.

And with that, we complete the first unit test.

Test to get the current user (test get_current_user)

Now let's move on to the next test: getting the current user, once they're logged in.

To do this, we'll create a new function called test_get_current_user.

Remember that all tests must begin with test_ so that pytest recognizes and runs them automatically. We pass it client and also test_user, because we need an existing user.

But not only that. We also need a valid token, because to query the current user, we need to be authenticated.

So, one of the things we could do is: after creating the user with test_user, we log in to obtain the token.

res = client.post("/login", data={
   "username": test_user["email"],
   "password": test_user["password"]
})

And now we extract the token from the response:

token = res.json()["access_token"]

Once we have the token, we can now make a GET request to the /me route, which returns the authenticated user.

But this route requires the Authorization header in the Bearer <token> format. So let's build that header.

headers = {
   "Authorization": f"Bearer {token}"
}

And now we make the request:

res = client.get("/me", headers=headers)

We can verify that the status code is 200:

assert res.status_code == 200

And if we want, we can also print the response to see what data it returns:

print(res.json())

Ready, we save and run the test.

Everything is correct. It returns the authenticated user, along with their ID, email, etc.

This ensures that the authentication system is working properly and that the token is being validated correctly.

Test: Get current user without token (test_get_current_user_no_token)

Let's now test the case where we don't send the token. That is, the user tries to access /me, but isn't authenticated.

Let's create a new test called test_get_current_user_no_token, and simply make the request without headers:

res = client.get("/me")

We check the status code:

assert res.status_code == 401

Done. This test is very important because it ensures that the protected routes actually require authentication.

If this test passed with a 200, we would have a serious security problem.

We ran it... and everything was fine: it returned a 401, as expected.

I agree to receive announcements of interest about this Blog.

We configured and performed the first tests with FastAPI and Pytest.

| 👤 Andrés Cruz

🇪🇸 En español