Pruebas automatizadas - Ejercicios

Nota

Las instrucciones de este documento asumen que estás utilizando Visual Studio Code. En la última sección se incluyen instrucciones para ejecutar los tests desde la terminal.

Creación de un proyecto

Para esta práctica, vas a trabajar en el proyecto pymath, estructurándolo de forma modular. Esto te permitirá organizar el código y los tests de manera profesional, y además facilitará la ejecución de pruebas y otras herramientas de análisis.

Estructura del proyecto

Crea la carpeta pymath para el proyecto con la siguiente estructura:

pymath/
├── src/
|   └── pymath/
|        └── functions.py   # Aquí irá el código fuente
├── tests/                  # Aquí irán los tests con unittest
├── .env                    # Variable de entorno para PYTHONPATH
└── .vscode/
    └── settings.json       # Configuración para que VS Code encuentre los módulos

Archivos iniciales

.env
PYTHONPATH=src

Este archivo permitirá que los tests funcionen correctamente al ejecutarlos desde la raíz del proyecto.


.vscode/settings.json
{
  "python.analysis.extraPaths": ["src"]
}

Si estás usando Visual Studio Code, añade este archivo para que el editor pueda resolver correctamente las importaciones de tus módulos.

Este paso evita errores como ModuleNotFoundError cuando importas pymath en los tests.


src/pymath/functions.py
# Aquí irá el código fuente

src/pymath/__init__.py
# Aquí exportaremos las funciones que queramos que sean accesibles al importar el módulo pymath

Creación de un entorno virtual

Crea y activa un entorno virtual para el proyecto. Esto te permitirá gestionar las dependencias de manera aislada y evitar conflictos con otras aplicaciones.

Función add

Añade la función add al archivo src/pymath/functions.py. Esta función suma dos números (de forma muy enrevesada) y devuelve el resultado. Nos servirá para realizar pruebas.

src/pymath/functions.py
def add(a: int, b: int) -> int:
    """
    Adds two integers.

    :param a: First integer
    :param b: Second integer
    :return: Sum of a and b
    """
    total = 0
    increment = 1
    if a < 0:
        a = -a
        increment = -1
    for i in range(int(a)):
        total += increment

    increment = 1
    if b < 0:
        b = -b
        increment = -1
    for j in range(int(b)):
        total += increment

    return total

Añade la función add al archivo src/pymath/__init__.py para que sea accesible al importar el módulo pymath.

src/pymath/__init__.py
from .functions import add

Probando la función add

Ahora que tenemos la función add, vamos a crear pruebas para asegurarnos de que funciona correctamente.

Crea un archivo test_add.py en la carpeta tests y añade la siguiente prueba unitaria para la función add.

tests/test_add.py
import unittest

from pymath import add

class TestAddFunction(unittest.TestCase):

    def test_add_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)

Ejecuta las pruebas para asegurarte de que todo funciona correctamente. Si todo está bien la ejecución de la prueba debería mostrarse en verde.

Cobertura de código

Para asegurarte de que tus pruebas cubren adecuadamente el código puedes utilizar la herramienta coverage. Para instalarla ejecuta el siguiente comando:

pip install coverage

Luego, ejecuta las pruebas con cobertura pulsando el botón Run Tests with Coverage en la parte superior derecha de la ventana de pruebas de Visual Studio Code.

Esto generará un informe de cobertura que te mostrará qué líneas de código han sido ejecutadas durante las pruebas y cuáles no.

Como podrás observar, además del código fuente de la aplicación, el informe también incluye el código de los tests y todos los ficheros del entorno virtual (si está en la misma carpeta).

Para filtrar el informe y mostrar solo la cobertura del código fuente de la aplicación puedes añadir un archivo .coveragerc en la raíz del proyecto con el siguiente contenido:

.coveragerc
[run]
omit =
    __init__.py
    ./tests/*
    .conda/*
    .venv/*

Vuelve a ejecutar las pruebas con cobertura y verás que el informe ahora solo muestra la cobertura del código fuente de la aplicación.

Ejercicio

Añade más pruebas para la función add en el archivo test_add.py con el objetivo de cubrir todos los casos posibles y obtener una cobertura en las pruebas del 100%. Asegúrate de cubrir diferentes casos, como:

  • Sumar números negativos.
  • Sumar cero.
  • Sumar un número positivo y uno negativo.
  • Sumar dos números negativos.

Función substract

Añade la función substract al archivo src/pymath/functions.py. Esta función resta dos números haciendo uso de add y devuelve el resultado.

src/pymath/functions.py
def substract(a: int, b: int) -> int:
    """
    Subtracts two integers.

    :param a: First integer
    :param b: Second integer
    :return: Difference of a and b
    """
    b = -b
    total = add(a, b)

    return total
Nota

Recuerda añadir la función substract al archivo src/pymath/__init__.py para que sea accesible al importar el módulo pymath.

Probando la función substract

Ahora que tenemos la función substract, vamos a crear pruebas para asegurarnos de que funciona correctamente.

tests/test_substract.py
import unittest

from pymath import substract

class TestSubstractFunction(unittest.TestCase):

    def test_subtract_positive_numbers(self):
        self.assertEqual(substract(5, 3), 2)

Aunque la prueba anterior es correcta, no tiene en cuenta que la función substract hace uso de la función add. Por lo tanto, si la función add no funciona correctamente, la función substract tampoco lo hará.

Hay varias alternativas para comprobar el funcionamiento de substract.

Comprobar que substract llama a add

Una forma de comprobar que substract llama a add es utilizando unittest.mock para simular el comportamiento de la función add. Esto te permitirá verificar que substract llama a add con los argumentos correctos.

tests/test_substract.py
import unittest
from unittest.mock import patch

from pymath import substract

class TestSubstractFunction(unittest.TestCase):

    def test_subtract_positive_numbers(self):
        self.assertEqual(substract(5, 3), 2)

    @patch('pymath.functions.add')
    def test_subtract_without_add(self, mock_add):
        mock_add.return_value = 2 # add siempre devuelve 2
        self.assertEqual(substract(5, 3), 2)
        mock_add.assert_called_once_with(5, -3)

Simular el comportamiento de add

Simulando el comportamiento de add puedes verificar que substract hace su trabajo correctamente sin depender de la implementación de add. Esto es útil para asegurarte de que substract funciona correctamente incluso si add tiene errores.

tests/test_substract.py
import unittest
from unittest.mock import patch

from pymath import substract

class TestSubstractFunction(unittest.TestCase):

    def test_subtract_positive_numbers(self):
        self.assertEqual(substract(5, 3), 2)

    @patch('pymath.functions.add')
    def test_subtract_without_add(self, mock_add):
        mock_add.return_value = 2 # add siempre devuelve 2
        self.assertEqual(substract(5, 3), 2)
        mock_add.assert_called_once_with(5, -3)

    @patch('pymath.functions.add')
    def test_substract_with_sum(self, mock_add):
        mock_add.side_effect = lambda a, b: a + b # Reemplaza el comportamiento de add
        self.assertEqual(substract(5, 3), 2)

Función divide

Añade la función divide al archivo src/pymath/functions.py. Esta función divide dos números y devuelve el resultado. Si el divisor es cero, lanza una excepción ValueError.

src/pymath/functions.py
def divide(a: int, b: int) -> float:
    """
    Divides two integers.

    :param a: Dividend
    :param b: Divisor
    :return: Quotient of a and b
    """
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
Nota

Recuerda añadir la función divide al archivo src/pymath/__init__.py para que sea accesible al importar el módulo pymath.

Probando la función divide

Ahora que tenemos la función divide, vamos a crear pruebas para asegurarnos de que funciona correctamente.

tests/test_divide.py
import unittest

from pymath import divide

class TestDivideFunction(unittest.TestCase):

    def test_divide_positive_numbers(self):
        self.assertEqual(divide(6, 3), 2)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(6, 0)

En este caso, la prueba test_divide_by_zero verifica que se lanza una excepción ValueError cuando se intenta dividir por cero.

Pruebas para logging

Al usar logging en el código, es importante asegurarse de que los mensajes de registro se generen correctamente.

Modifica el archivo src/pymath/functions.py para incluir un logger y registrar los resultados de las funciones.

src/pymath/functions.py
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def add(a: int, b: int) -> int:
    logger.info(f"Adding {a} and {b}")
    # resto de la función...

Añade ahora una prueba para verificar que el logger se llama correctamente.

tests/test_add.py
class TestAddFunction(unittest.TestCase):
    def test_logging(self):
        with self.assertLogs('pymath.functions', level='INFO') as log:
            add(2, 3)
            self.assertIn('INFO:pymath.functions:Adding 2 and 3', log.output)
Ejercicio

Añade mensajes de logging a las funciones substract y divide y las pruebas correspondientes para verificar que se registran correctamente.

Ejercicios

Añade estas funciones al archivo src/pymath/functions.py y crea las pruebas correspondientes en la carpeta tests, asegurándote de cubrir todos los casos posibles y obtener una cobertura del 100%.

src/pymath/functions.py
def multiply(a: int, b: int) -> int:
    logger.info(f"Multiplying {a} by {b}")

    if a == 0 or b == 0:
        return 0

    result = 0
    for _ in range(abs(b)):
        result = add(result, a)
        if abs(result) > 1_000_000:
            logger.error("Overflow detected")
            raise OverflowError("Result too large")

    return result if b > 0 else -result

def calculate_expression(a: int, b: int, op: str):
    logger.info(f"Calculating: {a} {op} {b}")
    if op == "+":
        return add(a, b)
    elif op == "-":
        return substract(a, b)
    elif op == "*":
        return multiply(a, b)
    elif op == "/":
        return divide(a, b)
    else:
        logger.error(f"Unsupported operation: {op}")
        raise ValueError(f"Unsupported operation '{op}'")

def safe_divide(a: int, b: int, default=None) -> float:
    try:
        return divide(a, b)
    except ValueError as e:
        logger.warning(f"Attempted to divide {a} by zero")
        if default is not None:
            return default
        raise

def average(numbers: list[int]) -> float:
    if not numbers:
        logger.warning("Empty list provided to average()")
        raise ValueError("Cannot compute average of empty list")
    if not all(isinstance(n, int) for n in numbers):
        logger.error("Non-integer value in average input")
        raise TypeError("All elements must be integers")

    total = 0
    for n in numbers:
        total = add(total, n)

    return divide(total, len(numbers))

Anexo. Uso de la terminal

El fichero .env y la configuración de VS Code son útiles para que el editor reconozca las rutas de los módulos. Sin embargo, si prefieres ejecutar los tests desde la terminal deberás establecer la variable de entorno PYTHONPATH manualmente. Puedes hacerlo de la siguiente manera:

# Linux / Mac
export PYTHONPATH=src
# Windows
set PYTHONPATH=src

Una vez establecida la variable de entorno, puedes ejecutar los tests desde la terminal con el siguiente comando:

python -m unittest discover -s tests

Esto ejecutará todos los tests en la carpeta tests y mostrará los resultados en la terminal.

Para ejecutar los tests con cobertura, puedes usar el siguiente comando:

coverage run -m unittest discover -s tests
coverage report -m

Esto ejecutará los tests y generará un informe de cobertura en la terminal. Si deseas generar un informe en formato HTML, puedes usar el siguiente comando:

coverage html

Esto generará un informe en formato HTML en la carpeta htmlcov, que podrás abrir en tu navegador para ver la cobertura de código de manera más visual.