Pruebas/Testing en FastAPI con Pytest

Video thumbnail

Las pruebas son una parte crucial en cualquier aplicación que vayamos a crear, sin importar la tecnología, siempre es recomendable realizar pruebas automáticas para probar el sistema cuando se implementen nuevos cambios; de esta forma nos ahorramos mucho tiempo ya que, no hay necesidad de realizar muchas de las pruebas de manera manual si no, ejecutando un simple comando.

Las pruebas unitarias consisten en probar los componentes de manera individual; en el caso de la aplicación que hemos construido, serían cada uno de los métodos de la API, al igual que cualquier otra dependencia de estos métodos; de esta manera, cuando se ejecutan estas pruebas automatizadas, si la aplicación pasa todas las pruebas, significa que no se encontraron errores, pero, si no pasa las pruebas, significa que hay que hacer cambios a nivel de la aplicación o pruebas unitarias implementadas.

pytest, para las pruebas unitarias

Para realizar las pruebas unitarias, utilizaremos la librería de pytest, que es una librería para realizar pruebas unitarias de Python (y no exclusiva de FastAPI). 
 

Usuario de pruebas

Ejecutamos esto como un módulo de Python. Este sería el de test_user. Ahí está, pasó. Vamos a ver la base de datos y evaluar el resto. Y sí, aquí está: funciona correctamente.

El proyecto que vamos a usar sera el siguiente:

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

Consideraciones sobre los headers y el client

Como puedes ver, no es necesario pasar los headers, ya que están implícitamente definidos en el client. Al ser un módulo de FastAPI, éste sabe que trabaja con peticiones tipo JSON, así que no hace falta pasarlos. Tampoco hace falta colocar async ni await, ya lo hace automáticamente.

Finalmente, hacemos la petición tipo POST, registramos al usuario y poco más que decir. Aquí es donde tú colocas lo que quieres evaluar. En mi caso, copié y pegué lo que ya tenía para no perder tiempo, pero puedes cambiar un poco las pruebas a tu gusto.

Lo que tú quieras realizar con las pruebas es bastante subjetivo; depende de ti cómo lo quieras evaluar. Por ejemplo, estamos chequeando el código de estado, lo cual siempre es una buena medida. Guardamos la respuesta en data para que sea más fácil de manejar.

En caso de que se devuelva un email, puedes evaluarlo también. Actualmente, si revisamos la respuesta del registro, lo que devuelve es un message, aunque también podrías devolver el usuario para evaluar el resultado, incluyendo su id. Como no lo pasamos nosotros, se genera automáticamente, pero puedes verificar otros campos.

Y bueno, como te digo, estas son posibles verificaciones que puedes hacer. Con esto completamos la primera prueba unitaria.

Probando el login (creación del token)

Vamos ahora a probar el método de login, es decir, la creación del token. Para esto, creamos una función test_login_user, que no va a devolver nada.

Definimos el payload, copiamos y colocamos el username. Quitamos lo demás innecesario.

Luego armamos la respuesta con el método token, que es tipo POST. En este caso, como es una petición vía formulario (form data), no podemos usar json. En vez de eso, usamos data=payload.

Desde aquí, hacemos los assert para evaluar la respuesta: verificamos que sea 200. Guardamos la respuesta como data = response.json() y comprobamos que devuelva un access_token.

Actualmente no hay ningún token creado, así que probamos el método anterior. Ahí está, pasó la prueba. Revisamos si se creó el token: sí, ahí lo tenemos.

Poco más que decir. Lo único diferente aquí es que, al pasar data por form-data, usamos data en lugar de json. Por lo demás, es exactamente la misma estructura.

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

Probando el logout

Ahora vamos con la función de logout. Esta prueba puedes tomarla como un pequeño reto. Es prácticamente lo mismo que ya hicimos antes, así que adelante.

Igualmente, la voy a implementar. Creamos test_logout, o el nombre que prefieras. Indicamos que no retorna nada.

Permitidme copiar el cuerpo porque es prácticamente igual. Colocamos los headers y usamos client. Aquí sí interesa definir los headers porque vamos a agregar un campo adicional. Además, incluimos los ya conocidos: Accept: application/json y Content-Type: application/json, aunque no enviemos nada.

Hacemos la petición normal y evaluamos la respuesta. Eso sí, la prueba necesita un token válido, así que lo definimos y guardamos.

Inclusive, podrías hacer una verificación extra, como hicimos cuando eliminamos una tarea: buscar si existe en la base de datos. Pero en este caso, asegurarte de que el token no exista.

Ya lo hicimos antes, así que no lo repetiremos aquí.

Finalmente llamamos a la función: test_logout. Pasó. Verificamos que se haya hecho la operación y listo. Ya con esto completamos las pruebas para el usuario.

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"

Creando una tarea con form-data

Vamos ahora con la operación de creación de tareas. Esta vez lo hacemos en otro archivo: test_task.py.

Nos traemos la cabecera —también podrías colocarla en un archivo global e importarla— y comenzamos.

Al igual que antes, me vas a permitir copiar porque es prácticamente igual. Creamos la firma: test_create_task, que devuelve None.

Pasamos los datos necesarios. No hacen falta headers, así que los quitamos. La petición es directa.

Colocamos test_create_task, ejecutamos y pasó. Verificamos si se creó la tarea. En mi caso, tenía hasta la 25, recargo y ahora tengo la 26. ¡Perfecto!

Por qué seguimos con las pruebas unitarias
En esta sección seguimos trabajando con pruebas unitarias. ¿Por qué? , como mencioné varias veces antes, usamos pytest, que aunque es potente, no está pensado específicamente para FastAPI. Es un módulo que puedes usar para testear cualquier cosa en Python, incluso sin frameworks.

Pero ahora vamos a usar TestClient de FastAPI, el cual está diseñado para esto. Como puedes ver en un fragmento de mi libro, lo importamos directamente desde FastAPI y nos permite crear pruebas de forma muy sencilla.

Antes vimos la forma difícil. Ahora veremos la forma fácil.

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']

Configuración básica con TestClient

Todas las configuraciones que hicimos antes, ahora las podemos lograr en cinco líneas de código:

  • Importamos el módulo.
  • Importamos la base de datos.
  • Importamos la app.
  • Creamos el cliente con TestClient.
  • Le pasamos la app y listo.

Como todo es propio de FastAPI, no hace falta agregar nada extra. app hace referencia a toda la aplicación, incluyendo rutas y middlewares.

Además, puedes sobreescribir dependencias si es necesario. Esto te permite usar otras bases de datos en tus pruebas, por ejemplo. De hecho, es lo ideal: tener una base de datos para producción, otra para desarrollo, y otra para testing.

Evaluaciones y sugerencias

Aquí puedes colocar lo que tú quieras evaluar. Por ejemplo, para hacerlo un poquito más interesante y cambiarlo un poco, copié y pegué porque era lo que hice antes, y para no perder tiempo en eso, cambié un poco las pruebas.

Como te digo, lo que tú quieras realizar con las pruebas es bastante subjetivo; depende de ti cómo lo quieras evaluar y qué consideres mejor.

Por ejemplo, aquí nuevamente chequeamos el código. Creo que eso es siempre una buena medida. Guardamos en data la respuesta para que sea más fácilmente manejable.

En caso de que devuelva el email, que no recuerdo si es lo que está devolviendo ahora... Bueno, lo podemos buscar por acá. Aquí lo tenemos: register. En este caso, está devolviendo —porque lo cambiamos antes— un mensaje. Pero también podrías devolver aquí el usuario y evaluar el resultado de esa manera.

También puedes verificar que tenga presente el ID. Obviamente no sabemos el ID porque no lo estamos pasando. Esto sí lo estamos pasando, por lo tanto debe coincidir perfectamente. Esto no sé qué estaba haciendo aquí, pero como te digo, es otra posible verificación que podrías realizar.

Y ya con esto completamos la primera prueba unitaria

Prueba para obtener el usuario actual (test_get_current_user)

Vamos ahora con la siguiente prueba: obtener el usuario actual, una vez que está logueado.

Para ello vamos a crear una nueva función que se llame test_get_current_user.

Recuerda que todas las pruebas deben comenzar con test_ para que pytest las reconozca y ejecute automáticamente. Le pasamos client y también test_user, porque necesitamos un usuario existente.

Pero no solo eso. Necesitamos también un token válido, porque para consultar el usuario actual necesitamos estar autenticados.

Entonces, una de las cosas que podríamos hacer es: luego de crear el usuario con test_user, hacemos un login para obtener el token.

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

Y ahora extraemos el token de la respuesta:

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

Una vez que tenemos el token, ahora sí podemos hacer una petición GET a la ruta /me, que es la encargada de retornar el usuario autenticado.

Pero esta ruta requiere el header Authorization con el formato Bearer <token>. Así que vamos a construir ese header.

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

Y ahora sí hacemos la petición:

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

Podemos verificar que el status code sea 200:

assert res.status_code == 200

Y si queremos, también podemos imprimir la respuesta para ver qué datos nos devuelve:

print(res.json())

Listo, guardamos y ejecutamos la prueba.

Todo correcto. Nos devuelve el usuario autenticado, con su ID, correo, etc.

Esto nos asegura que el sistema de autenticación funciona bien y que el token se está validando correctamente.

Prueba: obtener el usuario actual sin token (test_get_current_user_no_token)

Vamos a probar ahora el caso en que no enviamos el token. Es decir, el usuario intenta acceder a /me, pero sin estar autenticado.

Creamos una nueva prueba llamada test_get_current_user_no_token, y simplemente hacemos la petición sin headers:

res = client.get("/me")

Verificamos el status code:

assert res.status_code == 401

Listo. Esta prueba es muy importante porque nos asegura que las rutas protegidas realmente requieren autenticación.

Si esta prueba pasara con un 200, tendríamos un problema serio de seguridad.

La ejecutamos... y todo bien: nos devuelve 401, como corresponde.

Acepto recibir anuncios de interes sobre este Blog.

Configuramos y realizamos las primeras pruebas con FastAPI y Pytest.

- Andrés Cruz