Tutorial Campo de entrenamiento 2526

De Wiki de EGC
Revisión del 11:22 7 oct 2025 de Jmorenol (discusión | contribuciones) (Ejecución de Locust)
Saltar a: navegación, buscar

Automatización de pruebas software en una aplicación Flask

Parte 1: creamos pruebas para una aplicación sencilla

El objetivo de la primera parte de la práctica es que nos familiaricemos con diferentes tipos de pruebas usando una aplicación web desarrollada con Flask. Durante la práctica se implementarán los siguientes tipos de pruebas:

  1. Pruebas unitarias y de integración con pytest para comprobar la funcionalidad interna de la aplicación y los endpoints de la API.
  2. Pruebas de cobertura para medir qué porcentaje de código está cubierto por las pruebas.
  3. Pruebas de interfaz con Selenium para simular el comportamiento de un usuario interactuando con la interfaz web.
  4. Pruebas de carga con Locust para evaluar el rendimiento de la aplicación bajo diferentes niveles de tráfico.

Dependencias

Instala las dependencias necesarias (¡pero recuerda hacerlo en un entorno virtual!):

python3.12 -m venv .venv
source .venv/bin/activate
pip3.12 install flask pytest pytest-cov selenium locust webdriver-manager

Estructura del proyecto

flask_testing_project/
│
├── app/
│   ├── __init__.py       
│   ├── app.py
│   ├── models.py
│   ├── routes.py
│   └── templates/
│       └── tasks.html
│
├── tests/
│   ├── conftest.py
│   ├── test_unit.py
│   ├── test_integration.py
│   └── test_interface.py
│
└── locustfile.py

Desarrollo de la aplicación Flask

Vamos a crear una aplicación sencilla que gestione tareas. El usuario podrá ver las tareas en una interfaz web y agregar nuevas tareas utilizando un formulario. Además ofrece una API REST para la visualización y creación de tareas.

Código app/__init__.py:

# Indica que 'app' es un paquete Python y expone la factoría create_app.

from .app import create_app

Código app/app.py:

from flask import Flask
from app.routes import bp as tasks_blueprint

def create_app():
    app = Flask(__name__)
    app.register_blueprint(tasks_blueprint)
    return app


Código app/routes.py:

from flask import Blueprint, jsonify, request, render_template, redirect, url_for
from app.models import get_all_tasks, create_task

bp = Blueprint('tasks', __name__)

@bp.route('/')
def task_list():
    return render_template('tasks.html', tasks=get_all_tasks())

@bp.route('/tasks', methods=['GET'])
def get_tasks():
    return jsonify({'tasks': get_all_tasks()})

@bp.route('/add_task', methods=['POST'])
def add_task_html():
    title = request.form.get('title')
    try:
        create_task(title)
        return redirect(url_for('tasks.task_list'))
    except ValueError as e:
        return str(e), 400

@bp.route('/tasks', methods=['POST'])
def create_task_api():
    data = request.get_json()
    title = data.get('title') if data else None
    try:
        task = create_task(title)
        return jsonify(task), 201
    except ValueError as e:
        return jsonify({'error': str(e)}), 400

Código app/models.py:

tasks = [
    {'id': 1, 'title': 'Comprar pan', 'done': False},
    {'id': 2, 'title': 'Estudiar Python', 'done': False}
]

def get_all_tasks():
    """Devuelve la lista de tareas."""
    return tasks

def create_task(title):
    """Crea una nueva tarea con el título indicado."""
    if not title:
        raise ValueError("El título es necesario")
    new_task = {
        'id': tasks[-1]['id'] + 1 if tasks else 1,
        'title': title,
        'done': False
    }
    tasks.append(new_task)
    return new_task

Como puedes ver, la aplicación no usa una base de datos ni un repositorio. Tan solo se usa una lista global y un par de funciones para leer y crear tareas. En una aplicación real esta lista global sería reemplazada por una base de datos (como ocurre en UVLHUB). Usar una variable global hace que los datos se pierdan cada vez que se reinicia el servidor y pueda no funcionar correctamente si múltiples usuarios acceden a la vez. Sin embargo, para aprender a escribir pruebas, es una simplificación muy útil

Plantilla HTML

La plantilla tasks.html es la encargada de mostrar las tareas y proporcionar un formulario para agregar nuevas tareas.

Archivo app/templates/tasks.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Gestor de Tareas</title>
</head>
<body>
    <h1>Gestor de Tareas</h1>

    <form action="{{ url_for('tasks.add_task_html') }}" method="POST">
        <input type="text" name="title" placeholder="Añadir nueva tarea">
        <button type="submit">Añadir tarea</button>
    </form>

    <h2>Lista de Tareas:</h2>
    <ul>
        {% for task in tasks %}
            <li>{{ task.title }} {% if task.done %}(completada){% endif %}</li>
        {% endfor %}
    </ul>
</body>
</html>

Ejecuta la aplicación

Veamos la aplicación en acción:

export FLASK_APP=app.app:create_app
flask run

Interactúa con ella desde primero desde el navegador (http://localhost:5000), creando y visualizando las tareas usando el formulario web. Y luego también interactúa con la app mediante la API:

curl -X POST http://127.0.0.1:5000/tasks -H "Content-Type: application/json" \
    -d '{"title": "Leer documentación de github actions"}'
curl http://127.0.0.1:5000/tasks

Automatización de pruebas

Configuración del entorno de pruebas con conftest.py

El archivo conftest.py sirve para definir fixtures, que son funciones especiales que preparan el entorno antes de ejecutar los tests. En este proyecto, las fixtures garantizan que cada prueba empiece con datos limpios y una aplicación Flask lista para usarse:

  1. La fixture reset_task prepara los datos iniciales antes de cada test.
  2. La fixture test_client crea un cliente de pruebas que permite simular peticiones HTTP (como GET o POST) sin necesidad de arrancar el servidor real.

Por tanto, este archivo permite que las pruebas sean repetibles y aisladas.

import sys, os, pytest
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

from app.app import create_app
from app import models

@pytest.fixture
def test_client():
    """Crea la aplicación Flask en modo testing y devuelve su cliente HTTP."""
    app = create_app()
    app.testing = True
    return app.test_client()

@pytest.fixture(autouse=True)
def reset_tasks():
    """
    Fixture autouse (se ejecuta antes de cada test).
    Restablece el estado inicial de la lista de tareas.
    """
    models.tasks[:] = [
        {'id': 1, 'title': 'Comprar pan', 'done': False},
        {'id': 2, 'title': 'Estudiar Python', 'done': False}
    ]

Ten en cuenta que se ha usado sys.path.append por simplicidad, pero se considera una práctica un poco anticuada y frágil. Una alternativa más moderna y robusta, como has visto con Rosemary en UVLHUB, es convertir el proyecto en un paquete instalable. Para ello, se crearía un archivo setup.py en la raíz del proyecto y luego se instalaría en modo editable con el comando pip install -e .. De esta forma, pytest encontraría el paquete app automáticamente.

Pruebas unitarias con pytest

Las pruebas unitarias se centrarán en comprobar el comportamiento de funciones individuales del modelo, sin depender de Flask, HTTP ni base de datos.

Archivo tests/test_unit.py:

import pytest
from app import models


def test_get_all_tasks_returns_list_of_dicts():
    """get_all_tasks debe devolver una lista de tareas con formato correcto."""
    result = models.get_all_tasks()
    assert isinstance(result, list)
    assert all(isinstance(t, dict) for t in result)
    assert any(t['title'] == 'Comprar pan' for t in result)


def test_create_task_adds_new_item_and_increments_length():
    """create_task debe añadir una nueva tarea y aumentar la longitud de la lista."""
    initial_len = len(models.tasks)
    new_task = models.create_task("Aprender testing")
    assert len(models.tasks) == initial_len + 1
    assert new_task in models.tasks
    assert new_task['title'] == "Aprender testing"


def test_create_task_increments_id_sequentially():
    """Los IDs de las nuevas tareas deben incrementarse de forma secuencial."""
    last_id = models.tasks[-1]['id']
    new_task = models.create_task("Nueva tarea")
    assert new_task['id'] == last_id + 1


def test_create_task_raises_value_error_if_title_missing():
    """Si no se pasa un título, create_task debe lanzar ValueError."""
    with pytest.raises(ValueError):
        models.create_task("")

Lee el código de las pruebas e intenta deducir qué hace cada línea. En base a las pruebas manuales que has realizado antes, ¿crees que todas estas pruebas van a funcionar bien?

Pruebas de integración con pytest

Estas pruebas verifican que la app Flask completa funcione correctamente, comprobando las rutas, peticiones y respuestas HTTP.

Archivo tests/test_integration.py:

def test_get_tasks_endpoint_returns_existing_tasks(test_client):
    """
    GET /tasks debe devolver una lista JSON con las tareas iniciales.
    """
    response = test_client.get('/tasks')
    assert response.status_code == 200

    data = response.get_json()
    assert 'tasks' in data
    assert any(t['title'] == 'Comprar pan' for t in data['tasks'])


def test_create_task_endpoint_returns_201_and_json(test_client):
    """
    POST /tasks (API JSON) debe crear una nueva tarea y devolver status 201.
    """
    response = test_client.post('/tasks', json={'title': 'Nueva tarea'})
    assert response.status_code == 201

    data = response.get_json()
    assert data['title'] == 'Nueva tarea'
    assert 'id' in data and isinstance(data['id'], int)


def test_create_task_without_title_returns_400_error(test_client):
    """
    Si se intenta crear una tarea sin título, el servidor debe devolver error 400.
    """
    response = test_client.post('/tasks', json={})
    assert response.status_code == 400

    data = response.get_json()
    assert data['error'] == 'El título es necesario'


def test_add_task_html_redirects_and_renders_new_task(test_client):
    """
    POST /add_task (formulario HTML):
    - debe aceptar datos enviados por formulario,
    - redirigir a la lista de tareas,
    - y mostrar la nueva tarea en el HTML.
    """
    response = test_client.post(
        '/add_task',
        data={'title': 'Tarea desde HTML'},
        follow_redirects=True  # Sigue el redirect hasta la página final
    )

    # Comprobamos que la respuesta final es OK y contiene el título
    assert response.status_code == 200
    assert b'Tarea desde HTML' in response.data
    assert b'Gestor de Tareas' in response.data


def test_create_then_retrieve_task_from_api(test_client):
    """
    Flujo completo API:
    1. Crear una tarea con POST /tasks
    2. Recuperar todas las tareas con GET /tasks
    3. Verificar que la nueva está presente
    """
    test_client.post('/tasks', json={'title': 'Task persistente'})
    response = test_client.get('/tasks')
    data = response.get_json()

    titles = [t['title'] for t in data['tasks']]
    assert 'Task persistente' in titles

De nuevo, lee el código de las pruebas e intenta deducir qué hace cada línea. En base a las pruebas manuales que has realizado antes, ¿crees que todas estas pruebas van a funcionar bien?

Ejecución de las pruebas unitarias y de integración

pytest -v

Comprueba los resultados obtenidos. ¿Coinciden con lo que estabas esperando?


Pruebas de cobertura con pytest-cov

Para asegurarnos de que nuestras pruebas unitarias tienen una buena cobertura de código, vamos a utilizar pytest-cov, una herramienta que extiende pytest para generar un informe sobre qué porcentaje del código ha sido cubierto por las pruebas.

Y, ¿qué es la cobertura de código?

La cobertura de código mide el porcentaje de código ejecutado cuando se lanzan las pruebas. Esto nos ayuda a identificar las áreas de la aplicación que no están siendo probadas adecuadamente.

Medir la cobertura de las pruebas con pytest-cov
pytest --cov=app tests/

Este comando ejecutará todas las pruebas en la carpeta tests/ y generará un informe de cobertura que mostrará qué porcentaje del código fue ejecutado durante las pruebas.

Tras ejecutar la orden anterior deberías ver una salida del estilo de la siguiente:

------- coverage: xxx% -------

Name              Stmts   Miss  Cover
----------------  ------  ----  -----
app/__init__.py        1      0   100%
app/app.py             6      0   100%
app/models.py          9      0   100%
app/routes.py         26      2    92%
TOTAL                 42      2    95%


Donde 'Stmts' indica el número total de sentencias de cada archivo; 'Miss' indica el número de sentencias que no fueron ejecutadas por las pruebas; y 'Cover' el porcentaje de cobertura de las pruebas sobre cada archivo.

También se puede obtener un informe más detallado con:

pytest --cov=app --cov-report=html tests/

Esta orden generará un informe HTML con detalles de qué líneas de código fueron ejecutadas durante las pruebas y cuáles no. El informe se generará en una carpeta llamada htmlcov/.

Para visualizar el informe, abre el archivo htmlcov/index.html en tu navegador:

xdg-open htmlcov/index.html

Con este informe podrás ver función a función cuál es su cobertura, y te permitirá identificar nuevas pruebas que añadir para verificar estos casos no cubiertos.

Pruebas de interfaz con Selenium

Estas pruebas simulan la interacción de un usuario con la interfaz web de la aplicación a través de un navegador real.

Archivo tests/test_interface.py:

import os, time, pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from webdriver_manager.firefox import GeckoDriverManager


# === Configuración del navegador ===

def initialize_driver():
    """
    Inicializa un driver de Firefox con configuración compatible con sistemas snap.
    UVLHUB usa exactamente esta estructura.
    """
    options = webdriver.FirefoxOptions()

    # Directorio temporal alternativo (evita problemas con permisos en snap)
    snap_tmp = os.path.expanduser("~/snap/firefox/common/tmp")
    os.makedirs(snap_tmp, exist_ok=True)
    os.environ["TMPDIR"] = snap_tmp

    service = Service(GeckoDriverManager().install())
    driver = webdriver.Firefox(service=service, options=options)
    driver.set_window_size(1024, 768)
    return driver


def close_driver(driver):
    """Cierra el navegador."""
    driver.quit()


# === Tests de interfaz ===


@pytest.fixture(scope="module")
def driver():
    """
    Fixture que crea y cierra automáticamente el navegador antes y después de todos los tests del módulo.
    """
    d = initialize_driver()
    yield d
    close_driver(d)


def test_add_task_via_web_form(driver):
    """
    Flujo de prueba:
    1. Abrir la aplicación en http://localhost:5000/
    2. Escribir una nueva tarea en el formulario.
    3. Pulsar el botón 'Añadir tarea'.
    4. Comprobar que la nueva tarea aparece en la lista.
    """

    # 1️ Navegar a la página principal
    driver.get("http://localhost:5000/")
    time.sleep(1)  # pequeña espera para que la página cargue

    # 2️ Buscar el campo de texto y escribir la tarea
    input_box = driver.find_element(By.NAME, "title")
    input_box.clear()
    input_box.send_keys("Tarea Selenium")

    # 3️ Enviar el formulario
    submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
    submit_button.click()
    time.sleep(1)  # espera breve tras el redireccionamiento

    # 4️ Verificar que la nueva tarea aparece en la lista
    page_source = driver.page_source
    assert "Tarea Selenium" in page_source, "La nueva tarea no se muestra en la lista de tareas."

¿Qué crees que va a ocurrir cuando ejecutemos esta prueba?

Pues vamos a lanzarla y comprobemos qué ocurre:

pytest -s tests/test_interface.py

¿Has visto cómo se ha lanzado el navegador y ha ido realizando los pasos indicados en el archivo tests/test_interface.py?

En relación al código utilizado, ten en cuenta que se ha usado time.sleep(1) por simplicidad, pero es una práctica poco fiable. Una prueba puede fallar porque la red o el equipo van lentos y la página tarda más de 1 segundo en cargar. Si subes el tiempo (ej. time.sleep(10)), haces las pruebas innecesariamente lentas. La solución es utilizar esperas explícitas (explicit waits), tal como se verás que se hace en UVLHUB, con las que se indica a Selenium que espere hasta que una condición se cumpla (por ejemplo, hasta que un elemento sea visible), con un tiempo máximo de espera.

Selenium IDE

Y puede que estés pensando "sí, vale, las pruebas han funcionado como esperaba... pero si tuviera que escribir yo la prueba me costaría bastante trabajo".

Y es cierto, pero afortunadamente existe Selenium IDE, que es una herramienta fácil de usar que nos permite grabar interacciones en el navegador y convertirlas en scripts de prueba que pueden ser ejecutados automáticamente.

Instalar Selenium IDE

Selenium IDE está disponible como una extensión para los navegadores Firefox y Chrome. Una vez instalada la extensión, haz clic en el ícono de Selenium IDE en la barra de herramientas del navegador para abrirla.

Grabar una prueba con Selenium IDE

Iniciar una nueva grabación:

  • Abre Selenium IDE.
  • Selecciona Create a new project y dale un nombre a tu proyecto, por ejemplo, PruebasFlaskInterfaz.
  • Introduce la URL de la aplicación Flask en ejecución.

Grabar la interacción:

  • Haz clic en el botón de grabación en Selenium IDE.
  • Acción 1: Abre la página principal de la aplicación Flask.
  • Acción 2: En el formulario de tareas, escribe una nueva tarea, por ejemplo, "Tarea de Selenium IDE".
  • Acción 3: Haz clic en el botón para añadir la tarea.
  • Acción 4: Verifica que la nueva tarea aparece en la lista.
  • Detén la grabación una vez que hayas completado estos pasos.

Guardar la prueba en Selenium IDE.

Ejecutar la prueba grabada

En Selenium IDE, selecciona la prueba grabada y haz clic en Run current test. Observa cómo Selenium IDE reproduce automáticamente todas las acciones que realizaste durante la grabación (navegar, escribir en el formulario, etc.).

Exportar el test a código

Exportar a Python:

  • En Selenium IDE, selecciona el menú Export y elige Python - pytest.
  • Selecciona la carpeta de pruebas y guárdalo como test_selenium_ide.py.

Pruebas de carga con Locust

Locust simulará múltiples usuarios accediendo a la aplicación simultáneamente, realizando operaciones como cargar la lista de tareas y agregar nuevas tareas.

Archivo locustfile.py:

from locust import HttpUser, task, between

class WebsiteTestUser(HttpUser):
    wait_time = between(1, 5)

    @task(2)
    def load_tasks(self):
        print("Cargando la lista de tareas...")
        response = self.client.get("/tasks")
        if response.status_code == 200:
            print("Lista de tareas cargada correctamente.")
        else:
            print(f"Error al cargar la lista de tareas: {response.status_code}")

    @task(1)
    def create_task(self):
        print("Creando una nueva tarea...")
        response = self.client.post("/tasks", json={"title": "Tarea generada por Locust"})
        if response.status_code == 201:
            print("Tarea creada correctamente.")
        else:
            print(f"Error al crear la tarea: {response.status_code}")

Ten en cuenta que en esta demostración usamos print() para poder ver la actividad de los usuarios simulados en tiempo real en la consola. En una prueba de carga a gran escala se evitarían los print() porque escribir en la consola consume recursos (I/O) y puede afectar ligeramente los resultados de rendimiento. Locust ya proporciona estadísticas detalladas en su interfaz web, que es la forma profesional de analizar los resultados, como vas a ver a continuación.

Ejecución de Locust
  1. Inicia la aplicación Flask si no estaba en ejecución:
flask run
  1. Inicia Locust:
locust -f locustfile.py
  1. Abre la interfaz de Locust en tu navegador (http://localhost:8089) y configura el número de usuarios -por ejemplo, 10-, la tasa de generación (cada cuánto tiempo se lanza un nuevo usuario)-por ejemplo, 1- y el host sobre el que realizar las pruebas (http://localhost:5000). Luego, inicia la prueba.
  1. En la terminal verás mensajes como estos hasta que se haya lanzado el número de clientes indicado:
Cargando la lista de tareas...
Lista de tareas cargada correctamente.
Creando una nueva tarea...
Tarea creada correctamente.
Creando una nueva tarea...
Tarea creada correctamente.
Cargando la lista de tareas...
Lista de tareas cargada correctamente.
Creando una nueva tarea...
Tarea creada correctamente.
Creando una nueva tarea...
Tarea creada correctamente.
[2024-10-07 17:35:02,798] hostname/INFO/locust.runners: All users spawned: {"WebsiteTestUser": 10} (10 total users)

Y, además, en la interfaz de Locust en tu navegador (http://localhost:8089) puedes navegar por un informe interactivo con los resultados.

¿Cómo han ido las pruebas? ¿Ha aguantado el sistema esta carga?

Parte 2: Creamos pruebas para nuestra aplicación UVLHUB

Con lo que hemos aprendido hasta ahora, ya tenemos las herramientas necesarias para poder diseñar pruebas automatizadas para nuestra aplicación UVLHUB. Por ejemplo, ¿qué tipo de pruebas podrías desarrollar para probar la nueva funcionalidad de notepads que creaste en la primera práctica del curso?

No obstante, antes de lanzarte a ello quizá pueda ser buena idea echar un ojo a las pruebas ya existentes en el repositorio, que te pueden dar pistas, ideas y estrategias para diseñar las tuyas. Y al hacerlo verás que en UVLHUB se usa rosemary, que facilita todavía más las tareas de testing: https://docs.uvlhub.io/rosemary/testing.

Pero no te agobies por tener que aprender ahora algo nuevo como rosemary, ya que si echas un ojo al código del repositorio vas a ver que, en realidad, para lanzar las pruebas rosemary hace llamadas a pytest. Su uso es totalmente opcional, aunque es cierto nos hace la vida un poquito más fácil.

Un ejemplo sencillo para ayudarte a arrancar

Vamos a desarrollar una primera prueba para el módulo notepad que preparamos en la Práctica 1 del curso. Pero en lugar de hacerlo desde cero, vamos a ver si podemos inspirarnos con algunos de los tests ya creados en el repositorio. Por ejemplo, echa un ojo a los tests del módulo profile: https://github.com/EGCETSII/uvlhub/blob/main/app/modules/profile/tests/test_unit.py

Fíjate bien en la función test_edit_profile_page_get.

Imagina que ahora quisiéramos crear un test para comprobar que el usuario de pruebas puede solicitar la lista de sus notepads, que inicialmente está vacía, comprobando el flujo completo desde el navegador del usuario. ¿Podrías inspirarte en los tests del módulo profile para crear este otro? Sería muy similar, ¿verdad?

En el caso del notepad habría que hacer una petición get a /notepad, luego habría que comprobar que el código de respuesta es 200 y finalmente comprobar que en los datos de la respuesta aparece el texto "You have no notepads." Algo así, por ejemplo:

def test_list_empty_notepad_get(test_client):
    """
    Tests access to the empty notepad list via GET request.
    """
    login_response = login(test_client, "user@example.com", "test1234")
    assert login_response.status_code == 200, "Login was unsuccessful."

    response = test_client.get("/notepad")
    assert response.status_code == 200, "The notepad page could not be accessed."
    assert b"You have no notepads." in response.data, "The expected content is not present on the page"

    logout(test_client)

Algunas cuestiones que puedes investigar

Partiendo de este ejemplo anterior, seguro que podrías ir diseñando las pruebas necesarias para comprobar todas las operaciones CRUD del módulo notepad. Pero si has echado un ojo a otras pruebas de diferentes módulos de UVLHUB quizá te hayas encontrado con algunas cosas que no hemos visto en este campo de entrenamiento y que te pueden resultar muy útiles.

Por ejemplo, en UVLHUB se usan clases de servicio (como NotepadService, que creaste en la práctica 1). Estas clases encapsulan la lógica de negocio y se apoyan en un repositorio para acceder a la base de datos. Es probable que hayas visto pruebas unitarias en las que se utilizan mocks (a través de unittest.mock.patch y MagicMock) para simular el comportamiento del repositorio y así probar el servicio de forma aislada, sin tocar datos reales. Este patrón es muy común en aplicaciones reales, ya que facilita mantener las pruebas rápidas, independientes y centradas en una sola capa de la aplicación.

También verás que las fixtures de UVLHUB son más sofisticadas: además de crear un test_client, preparan datos persistentes en la base de datos usando SQLAlchemy, e incluso gestionan la sesión de usuario mediante Flask-Login. Esto permite probar funcionalidades que dependen del contexto del usuario autenticado, algo que no aparecía en la app de tareas de la primera parte de esta práctica.

Básicamente, estas técnicas amplían los fundamentos que ya has aprendido: la estructura y filosofía de las pruebas es la misma, pero ahora se aplican en un entorno más realista y con dependencias que reflejan la complejidad de una aplicación web completa.

¡Mucho ánimo!