Pruebas Unitarias en Flask con Pytest en Python

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.

¿Por qué hacer pruebas?

Las pruebas ayudan a garantizar que su aplicación funcionará como se espera y ha medida que la aplicación vaya creciendo en módulos y complejidad, se puedan implementar nuevas pruebas y adaptar las actuales.

Es importante mencionar que las pruebas no son perfectas, es decir, que aunque la aplicación pase todas las pruebas no significa que la aplicación está libre de errores, pero sí es un buen indicador inicial de la calidad del software. Además, el código comprobable es generalmente una señal de una buena arquitectura de software.

Las pruebas deben de formar parte del ciclo de desarrollo de la aplicación para garantizar su buen funcionamiento ejecutando las mismas constantemente.

¿Qué probar?

Las pruebas unitarias deberían centrarse en probar pequeñas unidades de código de forma aislada o individual.

Por ejemplo, en una aplicación Flask o una aplicación web en general:

  • Controladores:
    • Respuestas de las vistas
    • Códigos de estados
    • Condiciones nominales (GET, POST, etc.) para una función de vista
  • Formularios
  • Funciones de ayuda individuales

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 Flask).

https://flask.palletsprojects.com/en/latest/testing/

Instalemos pytest en nuestro proyecto mediante:

$ pip install pytest

Creando las primeras pruebas unitarias

Como buenas prácticas, crearemos una carpeta llamada tests para realizar las pruebas unitarias, que también es la ubicación por defecto que va a usar pytest para buscar las pruebas unitarias; también, creamos un archivo para realizar las primeras pruebas cuyos nombres usualmente contienen el prefijo de test_ para indicar que es una prueba unitaria:

my_app\tests\test_math_operations.py

En el cual, creamos unas funciones con que resuelvan las operaciones matemáticas:

my_app\tests\test_math_operations.py

def add(a: int , b: int) -> int:
    return a + b
def subtract(a: int, b: int) -> int:
    return b - a
def multiply(a: int, b: int) -> int:
    return a * b
def divide(a: int, b: int) -> int:
    return b / a

De momento, no son pruebas destinadas a la aplicación, si no, operaciones matemáticas básicas, pero, con ellas podremos conocer de una mejor manera el funcionamiento de las pruebas unitarias; ahora, en el archivo anterior, definimos las siguientes funciones, que corresponden a las pruebas unitarias:

my_app\tests\test_math_operations.py

*** 
# test
def test_add() -> None:
    assert add(1,2) == 3
def test_subtract() -> None:
    assert subtract(5,2) == -3
def test_multiply() -> None:
    assert multiply(10,10) == 100
def test_divide() -> None:
    assert divide(25,100) == 4

Como puedes apreciar, son solamente funciones de Python, pero, utilizando la palabra clave assert que se utiliza para verificar las salidas; específicamente estaremos probando que las operaciones aritméticas sean iguales a los valores esperados, es decir, que si para la operación de la suma, sumamos 1+1, el resultado esperado es un 2.

Finalmente, para probar las pruebas anteriores ejecutamos:

pytest my_app\tests\test_math_operations.py

También puedes ejecutar simplemente:

$ pytest

O ejecutar una función en particular:

$ pytest .\my_app\tests\test_task.py::<FUNCTION>

Por ejemplo:

$ pytest .\my_app\tests\test_task.py::test_add

Para ejecutar todas las pruebas unitarias, que en este caso corresponde solo a un archivo; en cualquiera de los casos tendremos una salida como la siguiente:

========================================= test session starts =========================================
platform darwin -- Python 3.11.2, pytest-7.4.0, pluggy-1.2.0
rootdir: ***
plugins: anyio-3.7.1
collected 4 items                                                                                     

tests/test_math_operations.py ....                                                              [100%]

========================================== 4 passed in 0.01s ==========================================

En la cual, como puedes apreciar, todas las operaciones fueron ejecutadas de manera satisfactoria, por lo tanto, la aplicación superó las pruebas; un factor que debes de tener en cuenta, es que, aunque la aplicación pase las pruebas, no garantiza que no tenga errores, simplemente que no se hicieron o definieron suficientes pruebas para probar todos los escenarios posibles; aun así, es un buen medidor para conocer el estado de la aplicación y reparar posibles problemas.

Si por ejemplo, colocamos una salida inválida al momento de hacer las pruebas unitarias:

def test_add() -> None:
    assert add(1, 2) == 4

Veremos una salida como la siguiente:

============================================================================= FAILURES ==============================================================================
_____________________________________________________________________________ test_add ______________________________________________________________________________

    def test_add() -> None:
>       assert add(1, 2) == 4
E       assert 3 == 4
E        +  where 3 = add(1, 2)

tests/test_math_operations.py:17: AssertionError
====================================================================== short test summary info ======================================================================
FAILED tests/test_math_operations.py::test_add - assert 3 == 4
==================================================================== 1 failed, 3 passed in 0.02s ====================================================================
En la cual te indica claramente que ocurrió un error y que corresponde a la operación de suma:
FAILED tests/test_math_operations.py::test_add - assert 3 == 4

Si por el contrario, el error es a nivel de la aplicación, por ejemplo, en la función de suma, en vez de sumar a+b, sumamos a+a:

def add(a: int , b: int) -> int:
    return a + a

Tendremos como salida:

FAILED tests/test_math_operations.py::test_add - assert 2 == 4

Pero, si en la función de salida fuera como la siguiente:

def test_add() -> None:
    assert add(1, 1) == 2

Veremos que la prueba unitaria es aceptada, aunque claramente tenemos problemas a nivel de la aplicación; por lo tanto, no es condición suficiente que la aplicación pase las pruebas para indicar que la misma no tiene problemas, simplemente no se crearon las pruebas suficientes o no se implementaron las pruebas de manera correcta.

conftest.py, generar un cliente para las pruebas

Debemos de crear un archivo llamado conftest.py que será el responsable de crear una instancia de la aplicación, que es requerida por los archivos de prueba que se van a conectar a nuestra API:

tests\conftest.py

Comencemos definiendo el fixture de la sesión de bucle:

my_app\tests\conftest.py

import pytest
from my_app import app as flask_app

@pytest.fixture
def app():
    yield flask_app

@pytest.fixture
def client(app):
    return app.test_client()

Este archivo inicializará nuestra aplicación Flask y todos los dispositivos que necesita y podemos realizar cualquier personalización sobre el cliente/app que necesitemos como pasar a modo de pruebas, cambiar la base de datos, etc, esto, lo veremos más en detalle un poco más adelante.

Ahora, Pytest descubrirá todos sus archivos de prueba, vamos a crear algunos archivos de prueba con el prefijo test_ en el mismo directorio. En este caso, probaremos que la ruta responda con el dictado esperado de hola mundo.

En el código anterior, emplean los yields para retornar el valor (cliente) y este se mantiene vivo hasta que finaliza la prueba unitaria de ejecutarse; es exactamente el mismo escenario cuando inyectamos la base de datos como argumentos de los métodos de la API y que se creaba una conexión por cada petición del usuario y esta petición se cerraba automáticamente al enviar la respuesta al cliente; esta estructura es útil por si necesitas hacer algún proceso adicional después de liberado el yield:

@pytest.fixture
def app():
    yield flask_app
    // TO DO Release resource, clean BD...

Al momento de ejecutar las pruebas unitarias que empleen el client que configuramos antes, probablemente la terminal arroje un error como:

my_app/tests/conftest.py:3: in <module>
    from my_app import app as flask_app
E   ModuleNotFoundError: No module named 'my_app'

Para solventarlo, convierte la carpeta tests en un módulo incluyendo el archivo de __init__.py:

Figura 11-1: Módulo de pruebas unitarias

Pruebas unitarias para el Hola Mundo

Ya conocemos cómo funcionan las pruebas unitarias, dimos el primer contacto mediante las operaciones matemáticas presentadas antes y creamos el archivo de configuración necesario por Pytest para crear un cliente para nuestra aplicación, específicamente la variable llamada app, que como conocemos es el que mantiene toda la aplicación, como conexión a las extensiones, base de datos, paquetes como Login Manager, Blueprint etc.

Para esta primera prueba, definimos un hola mundo devuelve en JSON como:

my_app\__init__.py

@app.route('/') 
@app.route('/hello') 
def hello_world(): 
    name = request.args.get('name', 'DesarrolloLibre') 
    return {'hello': 'world'}

Con este ejemplo, podremos entender de una manera más sencilla la implementación de una prueba unitaria sencilla sin entrar en detalles de otros elementos como evaluar una página web completa, evaluar en la base de datos, etc. Este ejemplo nos sirve como otro "Hola Mundo" pero con un enfoque más realista.

La prueba unitaria queda como:

my_app\tests\test_index.py

import json
def test_index(app, client):
    response = client.get('/')
    assert response.status_code == 200
    expected = {'hello': 'world'}
    assert expected == json.loads(response.get_data(as_text=True))

Con:

response.get_data(as_text=False)

Devuelve la respuesta en bytes.

Con:

response.get_data(as_text=True)

Devuelve la respuesta en un string.

Puedes obtener la documentación completa sobre las respuestas en:

https://tedboy.github.io/flask/generated/generated/flask.Response.html

- Andrés Cruz

In english
Andrés Cruz

Desarrollo con Laravel, Django, Flask, CodeIgniter, HTML5, CSS3, MySQL, JavaScript, Vue, Android, iOS, Flutter

Andrés Cruz En Udemy

Acepto recibir anuncios de interes sobre este Blog.