Ejecutando pruebas unitarias y cobertura en un Runner privado con pytest, pytest-cov y Flask en GitLab
La implementación de pruebas unitarias es una parte esencial del desarrollo de software, ya que ayuda a garantizar la calidad y funcionalidad del código. Pytest es una herramienta popular de pruebas en Python que ofrece una amplia gama de características y funcionalidades para escribir y ejecutar pruebas de manera eficiente. Flask, por otro lado, es un marco de desarrollo web en Python que es ampliamente utilizado para construir aplicaciones web.
En este contexto, la implementación de pruebas unitarias con Pytest en Flask en un runner privado de integración continua (CI) puede mejorar la calidad del código y acelerar el proceso de desarrollo. Un runner privado de CI es una instancia de ejecución de pruebas que se configura en un servidor local o en un entorno controlado, lo que permite ejecutar pruebas de manera automatizada y rápida en un entorno controlado antes de realizar la implementación en un entorno de producción.
En este artículo, exploraremos cómo implementar pruebas unitarias utilizando Pytest en Flask, y cómo configurar y utilizar un runner privado de CI para ejecutar estas pruebas de manera automatizada. Aprenderemos a escribir pruebas unitarias eficientes en Flask utilizando Pytest, cómo configurar un runner privado de CI para ejecutar las pruebas en un entorno controlado, y cómo integrar este proceso en el flujo de desarrollo para garantizar la calidad del código y acelerar el desarrollo de aplicaciones web en Flask.
Todo el código realizado se puede encontrar en el siguiente repositorio:
Requisitos
Este artículo es la continuación directa de la Creación y configuración de un Runner utilizando una VM en GCP.
Creación del programa en Python
Antes de comenzar con las pruebas unitarias, primero se creará una API simple en Python haciendo uso de un entorno virtual (pip env) para esto creamos el entorno utilizando Python 3.11 en el ordenador.
python3.11 -m venv venv
Esto nos creará una carpeta/directorio con el nombre "venv" en donde se gestionaran de manera independiente las dependencias y versiones de paquetes a utilizar.
Ahora creamos dos carpetas, una para almacenar nuestro código (src) y otra para almacenar nuestras pruebas unitarias (test).
Dentro de src creamos un archivo llamado app.py en donde configuraremos nuestra API.
También creamos un nuevo archivo llamado example_api.py en el cual escribiremos nuestro código para nuestra API, para esto haremos uso de Blueprint que nos permitirá definir rutas a nuestros endpoints.
Ahora crearemos nuestra API, esta regresará un Array de tamaño 10 con valores aleatorios, para comodidad crearemos un archivo llamado generator.py donde crearemos los métodos para generar una cadena aleatoria de 5 caracteres y un número aleatorio de 4 dígitos.
Ahora creamos nuestra API llamada /get_array_list de tipo GET para que esta pueda regresarnos los valores solicitados.
Finalmente regresamos a app.py, importamos nuestro archivo example_api.py y registramos nuestro blueprint "example_api" en nuestra instancia app para que las rutas y endpoints definidos estén disponibles en la aplicación de Flask.
Ahora abrimos una terminal y ejecutamos el siguiente comando:
source venv/bin/activate
Esto nos activa nuestro entorno virtual de python ubicado en la carpeta/directorio "venv".
Luego procedemos a instalar los requerimientos:
pip install flask
pip install flask-cors
Y creamos un archivo vacio llamado "__init__.py"
Ahora ejecutamos el siguiente comando para probar nuestra API (asegurarse de ejecutar el comando dentro de la carpeta /src):
flask run
Por medio de la aplicación de Postman podremos verificar el funcionamiento de nuestra API.
Pruebas unitarias
Para crear nuestras pruebas unitarias primero debemos de instalar pytest por medio del siguiente comando:
pip install pytest
Ahora debemos de dirigirnos a la carpeta /test y crear un archivo "__init__.py" (totalmente vacio), un archivo el cual llamaremos "test_correct.py" y otro llamado "conftest.py".
Dentro del archivo conftest.py definimos dos fixtures, que son funciones que proporcionan datos o configuraciones de pruebas.
La primera fixture llamada "app" crea una aplicación Flask utilizando la función "create_app()" importada del módulo "src.app". La función "create_app()" es la función que crea una instancia de la aplicación Flask con una configuración específica. La aplicación Flask creada se almacena en la variable "app" y se utiliza como contexto en el cual se ejecutan las pruebas. La función "yield app" permite que la aplicación Flask creada sea utilizada en las pruebas, y luego se ejecuta el código después de la instrucción "yield" cuando las pruebas han finalizado.
La segunda fixture llamada "client" utiliza la fixture "app" previamente definida como argumento. Luego, utiliza la función "test_client()" de Flask para crear un cliente de pruebas para la aplicación Flask creada en la fixture "app". El cliente de pruebas se almacena en la variable "client" y se devuelve como resultado de la fixture. Este cliente de pruebas se utiliza en las pruebas para hacer solicitudes HTTP a la aplicación Flask y realizar pruebas de integración.
Ahora crearemos nuestra prueba unitaria, para esto haremos uso del siguiente código:
La función de prueba utiliza la biblioteca unittest.mock para realizar parches (mocks) en las funciones generar_cadena_aleatoria() y generar_numero_aleatorio() de la API, reemplazando su comportamiento por valores fijos.
El flujo de la prueba es el siguiente:
Podemos verificar el funcionamiento de nuestra prueba unitaria por medio del comando:
pytest test/test_correct.py
Con esto comprobamos que nuestra prueba unitaria se ha ejecutado con éxito.
Ahora crearemos una prueba unitaria de fracaso, esta evaluará aquellos casos donde la aplicación regresa un mensaje de error como por ejemplo el utilizar POST en lugar de GET para acceder al endpoint. Para esto creamos un archivo en la carpeta /test llamado "test_incorrect.py".
Ahora crearemos nuestra prueba unitaria de fracaso, para esto haremos uso del siguiente código:
El flujo de la prueba es el siguiente:
Podemos verificar el funcionamiento de nuestra prueba unitaria por medio del comando:
pytest test/test_incorrect.py
Con esto comprobamos que nuestra prueba unitaria de fracaso se ha ejecutado con éxito.
Coverage (Pytest-cov)
El "coverage" es una medida utilizada en pruebas de software que indica la cantidad de código fuente que ha sido ejecutado durante la ejecución de un conjunto de pruebas. Es una métrica que permite evaluar la efectividad de las pruebas en términos de cuánto del código fuente ha sido probado.
El "coverage" se expresa típicamente como un porcentaje, que representa la proporción del código fuente que ha sido ejecutada al menos una vez durante las pruebas en relación con el total del código fuente. Un "coverage" del 100% significa que todas las líneas de código han sido ejecutadas al menos una vez durante las pruebas, lo cual indica que se ha probado todo el código (algo muy difícil mientras mayor cantidad de líneas posea el código).
El "coverage" es una herramienta útil para identificar áreas del código que no han sido probadas y pueden contener posibles errores o comportamientos inesperados. Una alta cobertura de pruebas indica que se ha realizado una exhaustiva prueba del código, lo que puede aumentar la confianza en la calidad del software. Aun así hay que tener en cuenta que el "coverage" no garantiza la ausencia de errores, ya que solo mide la cantidad de código ejecutado, no la calidad de las pruebas o la corrección del comportamiento del software.
Para poder incorporar el "coverage" en nuestro código en primer lugar debemos de instalar la librería por medio del siguiente comando:
pip install pytest-cov
Una vez hecho esto, crearemos un archivo en la raiz de nuestro proyecto llamado "cov_behavior.py".
Dentro crearemos el siguiente codigo:
El código importa el módulo xml.etree.ElementTree como ET, que es una biblioteca estándar de Python para trabajar con XML. Luego, se define el nombre del archivo XML de cobertura de pruebas como coverage_xml.
Recomendado por LinkedIn
A continuación, se utiliza ET.parse() para analizar el archivo XML y obtener un objeto de árbol (tree) que representa la estructura del documento XML. A partir de este objeto de árbol, se obtiene el elemento raíz del XML utilizando tree.getroot() y se almacena en la variable root.
Luego, se extraen los valores de dos atributos del elemento raíz del XML, que son lines-covered y lines-valid, utilizando root.attrib['lines-covered'] y root.attrib['lines-valid'] respectivamente. Estos atributos representan la cantidad de líneas cubiertas y la cantidad total de líneas válidas en el código fuente, según el informe de cobertura de pruebas.
A continuación, se calcula el porcentaje de cobertura dividiendo la cantidad de líneas cubiertas entre la cantidad total de líneas válidas, multiplicado por 100, y redondeando el resultado a dos decimales. El resultado se almacena en la variable coverage_percent.
Finalmente, se imprime el porcentaje de cobertura en un mensaje de texto formateado utilizando print(). Si el porcentaje de cobertura es menor al 75%, se lanza una excepción ValueError con un mensaje indicando que la cobertura es menor al 75% y se sugiere tomar una acción. Esto es utilizado como una forma de alerta para garantizar que se cumpla con un umbral mínimo de cobertura de pruebas en un proyecto de desarrollo de software. Hay que recordar que este porcentaje es arbitrario y dependerá de la elección de cada persona.
Por supuesto este código asume que se posee un archivo llamado coverage.xml en donde se encuentra la información completa del coverage. Para generar este archivo modificamos el comando para ejecutar las pruebas unitarias para que al ejecutarse esta genere un archivo .xml con la información de la prueba realizada:
pytest --cov=src --cov-report=xml test/test_correct.py
Este mismo comando lo podemos utilizar para obtener el reporte en las pruebas de fracaso pero al hacerlo surge un problema, el archivo .xml con la información de las pruebas es sobreescrito eliminando el reporte de las pruebas anteriores haciendo que el programa solo tome en cuenta las lineas alcanzadas por la prueba actual y no la anterior.
Para evitar esto existen dos soluciones:
En este caso usaremos la solución número dos lo que nos deja con el siguiente comando:
pytest --cov-append --cov=src --cov-report=xml test/test_incorrect.py
Al hacer esto nuestro reporte se añade al archivo .xml permitiendo tener múltiples archivos con distintos tipos de pruebas unitarias.
Ahora para obtener el reporte final de "coverage" ejecutamos nuestro archivo "cov_behavior.py"
python cov_behavior.py
Ejecución de pruebas unitarias en un Runner
Para realizar la ejecución de las pruebas unitarias en el Runner, en primer lugar debemos de modificar el Runner agregando algunas dependencias necesarias para que estas pueda ejecutarse. En este caso se agregará únicamente el entorno de Python3-venv dado que todo el proyecto ha sido trabajado ahí.
Para esto nos dirigimos a nuestra consola de VM donde se aloja nuestro Runner y ejecutamos el siguiente comando:
sudo -i
Con esto accederemos al usuario root del sistema, ahora verificamos si poseemos Python 3.11 instalado por medio del siguiente comando:
python3.11 --version
Con esto podemos observar que Python 3.11 no se encuentra instalado en el sistema. Para instalarlo agregamos en primer lugar el repositorio de python con el siguiente comando:
sudo add-apt-repository ppa:deadsnakes/ppa
Al hacerlo nos solicitara presionar la tecla enter para continuar:
Al terminar de agregar el repositorio, ejecutamos el siguiente comando para instalar Python 3.11
sudo apt install python3.11 -y
Y verificamos nuevamente su instalación:
python3.11 --version
Ahora procedemos a instalar Python 3 venv con el siguiente comando:
sudo apt install python3.11-venv -y
Ahora en nuestro código procedemos a generar un archivo de requisitos (requirements.txt) que contiene una lista de todas las dependencias de un proyecto específico y sus versiones actuales por medio del siguiente comando:
pip freeze > requirements.txt
Cuando se ejecuta este comando, "pip freeze" genera una lista de todas las bibliotecas instaladas en el entorno virtual de Python junto con sus versiones correspondientes. Luego, el operador de redirección (>) se utiliza para redirigir la salida de la lista a un archivo llamado requirements.txt.
El archivo requirements.txt generado puede ser utilizado posteriormente para recrear exactamente el mismo entorno virtual de Python con las mismas versiones de las dependencias. Esto es útil para compartir el entorno de desarrollo con otros desarrolladores, o para asegurarse de que todas las dependencias del proyecto sean consistentes en diferentes entornos de desarrollo o de producción.
Ahora en nuestro programa, procedemos a crear un archivo llamado ".gitlab-ci.yml". Este es un archivo de configuración utilizado en GitLab CI/CD (Continuous Integration/Continuous Deployment) para definir y personalizar los flujos de trabajo de integración continua y entrega continua en un proyecto de GitLab. Este archivo se encuentra en la raíz del repositorio de GitLab y contiene instrucciones en formato YAML (YAML Ain't Markup Language) que especifican cómo se deben construir, probar y desplegar las aplicaciones en un entorno de integración continua.
El archivo .gitlab-ci.yml define los trabajos (jobs) y las etapas (stages) que se ejecutarán en el pipeline de CI/CD. Cada trabajo es una tarea específica que se debe realizar, como compilar el código, ejecutar pruebas, desplegar la aplicación, entre otros. Las etapas agrupan los trabajos relacionados y definen el orden en que se ejecutan en el pipeline.
El archivo .gitlab-ci.yml también permite configurar variables de entorno, definir reglas de ejecución condicional basadas en ramas o etiquetas, especificar imágenes de Docker para los trabajos, y definir scripts de ejecución personalizados utilizando comandos de shell u otras herramientas.
El sistema de integración continua de GitLab CI/CD utiliza el archivo .gitlab-ci.yml como base para crear y ejecutar pipelines de CI/CD automáticamente cada vez que se realiza un cambio en el repositorio, lo que permite automatizar y estandarizar el proceso de construcción, pruebas y despliegue de aplicaciones en un proyecto de GitLab.
En este caso crearemos únicamente un stage llamado "Pruebas unitarias" en el cual se ejecutaran tres trabajos:
El código quedará de la siguiente forma:
Una vez finalizado procedemos a subir nuestro código a gitlab.
En nuestro repositorio, nos dirigimos a la opción de "CI/CD" ubicada en la parte izquierda.
Al ingresar nos mostrará un listado de todas las pruebas unitarias que se han realizado por cada commit:
Si hacemos clic en algún cheque de los stages podremos observar el nombre del stage así como los nombres de cada trabajo realizado
Si queremos obtener más información de esta simplemente seleccionamos el trabajo que nos interesa (por ejemplo Coverage) lo que nos llevará a una nueva ventana en donde podremos ver la ejecución de las tareas en el Runner
Pipeline fallido
En dado caso que un pipeline falle, este se mostrará de otro color y gitlab nos informará automáticamente a nuestro correo de este fallo.
Estas fallas ocurren principalmente por algún trabajo mal configurado y podremos visualizar cuál de ellas es al dar clic en su stage.
Aunque parezca que todos fallaron, hay que tomar en cuenta el orden con el que fueron escritos en el archivo ".gitlab-ci.yml". En este caso el primer trabajo escrito fue la "Ejecución de pruebas de exito" por lo que nos dirigiremos a este trabajo.
Una vez dentro, podremos observar la causa del error en nuestro código. En este caso el error fue causado por la falta de la instalación del plugin "pytest" debido a que no se incluyó en el archivo de "requirements.txt" por lo que al incluirlo en el código y volver a subir este al repositorio el error debería de desaparecer permitiendo la ejecución correcta de todas las pruebas unitarias.
Hay que tener en cuenta que el error no siempre se encontrará en nuestro código. Si estamos utilizando un Runner privado como en este caso, un error frecuente al ejecutar los trabajos por primera vez puede ser la falta de algún programa interno, configuración del Runner, permisos de usuario, etc. Por lo cual se recomienda realizar una configuración correcta de todo lo el entorno que se utilizará para ejecutar los comandos pertinentes.
Por último, si uno esta iniciando en el mundo de CI/CD y es su primera vez utilizando GitLab Runner, es muy probable que no pueda ejecutar estos trabajos en ningún Runner debido a una restricción implementada en los últimos años por parte de GitLab a los usuarios que no han sido verificados. Esto se debe a varios motivos:
Como dato curioso, esta decisión se realizó principalmente debido a que los Runners públicos costeados por GitLab y otras personas estaban siendo utilizados para minar crypto. Desde Mayo 17 de 2021 todos los usuarios creados deben de verificar su cuenta por medio de una transacción bancaria de 1 dolar (esta transacción funciona de manear similar a la realizada al crear la cuenta en GCP regresando ese dolar a la cuenta original de forma automática).
Fuentes