Pruebas Unitarias en Python

Por Jose R. Zapata

Ultima actualización: 27/Mar/2025

Invítame a un Cafe


Pruebas Unitarias

Las pruebas unitarias son una técnica de desarrollo de software que consiste en verificar el correcto funcionamiento de unidades individuales de código. Cada “unidad” generalmente es una función, método o clase. Las pruebas unitarias son fundamentales en el desarrollo de software de calidad y son particularmente importantes en proyectos de ciencia de datos para garantizar que los modelos, transformaciones y funciones analíticas funcionen correctamente.

¿Por qué son importantes las pruebas unitarias?

  1. Detectan errores temprano: Permiten identificar problemas antes de que lleguen a producción.
  2. Facilitan los cambios: Proveen confianza para modificar código existente.
  3. Documentan el código: Las pruebas describen cómo debe funcionar el código.
  4. Mejoran el diseño: Escribir código testeable fomenta mejores prácticas de diseño.
  5. Reducen los costos de mantenimiento: El código bien probado es más fácil de mantener.

Instalación de pytest

pytest es uno de los frameworks más populares para realizar pruebas en Python. Para instalarlo:

Con UV

uv add pytest pytest-cov

Con pip

pip install pytest pytest-cov

Organización del código para pruebas

Para mantener una buena organización, es recomendable seguir estas convenciones:

  1. Se debe crear un directorio tests en el mismo nivel que src.
  2. Crear archivo __init__.py vacio en cada uno de los directorios de src y tests, con el fin de que puedan ser tratados como paquetes y las pruebas puedan ser importadas.
  3. Los archivos y las funciones de prueba deben comenzar con test_.

Probando funciones reales

Vamos a crear una función simple y su correspondiente prueba:

# archivo: src/app/calc/calculadora.py
def sumar(a: int | float, b: int | float) -> int | float:
    """Suma dos números.

    Args:
        a (int, float): El primer número.
        b (int, float): El segundo número.

    Returns:
        int, float: La suma de los dos números.
    """
    return a + b


def restar(a: int | float, b: int | float) -> int | float:
    """Resta dos números.

    Args:
        a (int, float): El primer número.
        b (int, float): El segundo número.

    Returns:
        int, float: La resta de los dos números.
    """
    return a - b


def multiplicar(a: int | float, b: int | float) -> int | float:
    """Multiplica dos números.

    Args:
        a (int, float): El primer número.
        b (int, float): El segundo número.

    Returns:
        int, float: La multiplicación de los dos números.
    """
    return a * b


def dividir(a: int | float, b: int | float) -> int | float:
    """Divide dos números.

    Args:
        a (int, float): El primer número.
        b (int, float): El segundo número.

    Returns:
        int, float: La división de los dos números.

    Raises:
        ValueError: Si el segundo número es cero.
    """
    if b == 0:
        raise ValueError("No se puede dividir por cero")
    return a / b
# archivo: tests/app/calc/test_calculadora.py
import pytest

from src.app.calculadora import dividir, multiplicar, restar, sumar


def test_sumar() -> None:
    """Test de la función sumar."""
    assert sumar(3, 2) == 5
    assert sumar(-1, 1) == 0
    assert sumar(-1, -1) == -2


def test_restar() -> None:
    """Test de la función restar."""
    assert restar(5, 3) == 2
    assert restar(3, 5) == -2
    assert restar(0, 0) == 0


def test_multiplicar() -> None:
    """Test de la función multiplicar."""
    assert multiplicar(2, 3) == 6
    assert multiplicar(0, 5) == 0
    assert multiplicar(-2, 3) == -6


def test_dividir() -> None:
    """Test de la función dividir."""
    assert dividir(6, 3) == 2
    assert dividir(5, 2) == 2.5
    assert dividir(-6, 2) == -3


def test_division_por_cero() -> None:
    """Test de la función dividir por cero."""
    with pytest.raises(ValueError):
        dividir(5, 0)

Para ejecutar solo estos tests:

pytest -v tests/app/test_calculadora.py

Agrupar pruebas con clases

Una forma de agrupar pruebas es usar clases. Cada método de la clase debe comenzar con test_. la principal ventaja de usar clases es que se agrupan las pruebas relacionadas a un mismo componente.

Ejemplo con clases:

# tests/app/calc/test_calculadora_clase.py
import pytest
from src.app.calculadora import dividir, multiplicar, restar, sumar

class TestCalculadora:
    """Clase de pruebas para la calculadora."""

    def test_sumar(self) -> None:
        """Test de la función sumar."""
        assert sumar(3, 2) == 5
        assert sumar(-1, 1) == 0

    def test_restar(self) -> None:
        """Test de la función restar."""
        assert restar(5, 3) == 2
        assert restar(3, 5) == -2

    def test_multiplicar(self) -> None:
        """Test de la función multiplicar."""
        assert multiplicar(2, 3) == 6
        assert multiplicar(-2, 3) == -6

    def test_dividir(self) -> None:
        """Test de la función dividir."""
        assert dividir(6, 3) == 2
        assert dividir(5, 2) == 2.5

    def test_division_por_cero(self) -> None:
        """Test de la función dividir por cero."""
        with pytest.raises(ValueError):
            dividir(5, 0)

Fixtures: preparación de datos para pruebas

Las fixtures son funciones que pytest ejecuta antes (y a veces después) de las pruebas para preparar datos, conexiones, etc.

# tests/app/test_con_fixtures.py
import numpy as np
import pandas as pd
import pytest

# pylint: disable=redefined-outer-name
@pytest.fixture
def datos_ejemplo() -> pd.DataFrame:
    """Fixture que genera un DataFrame de ejemplo."""
    return pd.DataFrame({
        "A": np.random.rand(5),
        "B": np.random.rand(5),
        "C": np.random.rand(5),
    })


def test_suma_columnas(datos_ejemplo: pd.DataFrame) -> None:
    """Test de la suma de columnas."""
    # La fixture 'datos_ejemplo' se pasa automáticamente como argumento
    resultado = datos_ejemplo["A"] + datos_ejemplo["B"]
    assert len(resultado) == 5
    assert isinstance(resultado, pd.Series)


def test_media_columna(datos_ejemplo: pd.DataFrame) -> None:
    """Test de la media de columnas."""
    media = datos_ejemplo["C"].mean()
    assert 0 <= media <= 1

Parametrización de pruebas

Para probar una función con diferentes inputs:

# test_parametrizado.py
import pytest

from src.app.calculadora import sumar


@pytest.mark.parametrize(
    "a, b, esperado", [(3, 5, 8), (-1, 1, 0), (0, 0, 0), (-5, -5, -10)]
)
def test_sumar_parametrizado(
    a: int | float, b: int | float, esperado: int | float
) -> None:
    """Test de la función sumar parametrizada."""
    assert sumar(a, b) == esperado

Pruebas de excepciones

Cómo probar que una función levanta una excepción esperada:

import pytest

from src.app.calc.calculadora import dividir


def test_division_por_cero() -> None:
    """Test de la función dividir cuando se intenta dividir por cero."""
    with pytest.raises(ValueError) as excinfo:
        dividir(10, 0)

    assert "No se puede dividir por cero" in str(excinfo.value)


def test_division_correcta() -> None:
    """Test de la función dividir con valores válidos."""
    assert dividir(10, 2) == 5
    assert dividir(-10, 2) == -5
    assert dividir(5, 2) == 2.5

Pruebas con datos de Ciencia de Datos

Ejemplo de pruebas para funciones de procesamiento de datos:

# procesamiento.py
import pandas as pd


def normalizar(serie: pd.Series) -> pd.Series:
    """Normaliza una serie a valores entre 0 y 1.

    Args:
        serie (pd.Series): Serie de datos

    Returns:
        pd.Series: Serie normalizada
    """
    minimo = serie.min()
    maximo = serie.max()
    if maximo == minimo:
        return serie * 0  # Retorna serie de ceros si no hay variación
    return (serie - minimo) / (maximo - minimo)


def calcular_outliers_iqr(serie: pd.Series, factor: float = 1.5) -> pd.Series:
    """Identifica outliers usando el método IQR

    Args:
        serie (pd.Series): Serie de datos
        factor (float, optional):   Factor de multiplicación para el IQR.
                                    Default 1.5.

    Returns:
        pd.Series: Serie con los valores identificados como outliers
    """
    cuartil_1 = serie.quantile(0.25)
    cuartil_3 = serie.quantile(0.75)
    rango_intercuartil = cuartil_3 - cuartil_1
    limite_inferior = cuartil_1 - factor * rango_intercuartil
    limite_superior = cuartil_3 + factor * rango_intercuartil
    return serie[(serie < limite_inferior) | (serie > limite_superior)]
""" tests/app/data_process/test_procesamiento.py
Pruebas unitarias para el módulo de procesamiento de datos.
"""

import numpy as np
import pandas as pd
import pytest

from src.app.data_process.procesamiento import (
    calcular_outliers_iqr,
    limpiar_datos,
    normalizar,
)


# pylint: disable=redefined-outer-name
@pytest.fixture
def df_con_nulos() -> pd.DataFrame:
    return pd.DataFrame({
        "A": [1, 2, np.nan, 4, 5],
        "B": ["1", "2.5", "texto", "4", None],
        "C": [True, False, True, False, True],
    })


def test_normalizar() -> None:
    """
    Verifica que la función `normalizar` normaliza una serie de datos.
    """
    serie = pd.Series([10, 20, 30, 40, 50])
    normalizada = normalizar(serie)

    # Los valores deben estar entre 0 y 1
    assert normalizada.min() == 0
    assert normalizada.max() == 1

    # Verificar valores específicos
    assert normalizada.iloc[0] == 0
    assert normalizada.iloc[-1] == 1
    assert normalizada.iloc[2] == 0.5


def test_normalizar_serie_constante() -> None:
    """
    Verifica que la función `normalizar` normaliza una serie de datos.
    """
    serie = pd.Series([5, 5, 5, 5])
    normalizada = normalizar(serie)

    # Si todos los valores son iguales, todos se convierten a 0
    assert (normalizada == 0).all()


def test_calcular_outliers() -> None:
    """
    Verifica que la función `calcular_outliers_iqr` detecta correctamente
    los valores atípicos.
    """
    # Serie con un outlier extremo
    serie = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 100])
    outliers = calcular_outliers_iqr(serie)

    # Debería detectar el valor 100 como outlier
    assert len(outliers) == 1
    assert outliers.iloc[0] == 100

    # Serie con valores más dispersos para probar factores diferentes
    serie_dispersa = pd.Series([1, 2, 3, 5, 8, 13, 21, 34, 55, 89])

    # Con el factor por defecto (1.5), el valor 89 ya es detectado como outlier
    outliers_estandar = calcular_outliers_iqr(serie_dispersa)
    assert len(outliers_estandar) == 1
    assert 89 in outliers_estandar.values

    # Con un factor más pequeño, debería detectar más outliers
    outliers_ampliados = calcular_outliers_iqr(serie_dispersa, factor=0.3)
    assert len(outliers_ampliados) > 1

    # Verificar que los valores 55 y 89 están entre los outliers
    assert 55 in outliers_ampliados.values
    assert 89 in outliers_ampliados.values

Cobertura de código

Para medir qué porcentaje de nuestro código está cubierto por pruebas:

pytest --cov

Para generar un reporte detallado:

pytest -v --cov

Integración con CI/CD

Para integrar las pruebas en pipelines de CI/CD se puede usar workflows de github actions si el código está en github y el proyecto esta gestionado con UV.

La opción --cov-report=xml genera un reporte de cobertura en formato XML que puede ser utilizado por herramientas como codecov para visualizar la cobertura de código.

name: Tests

on:
  pull_request:
  push:
    branches:
      - "main"

jobs:

  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version-file: ".python-version"

      - name: Install the project
        run: uv sync --all-extras --dev

      - name: Run pytest with coverage
        run: uv run pytest --cov --cov-report=xml

Buenas prácticas para las pruebas unitarias

  1. Pruebas independientes: Cada prueba debe poder ejecutarse por sí sola.
  2. Pruebas rápidas: Las pruebas unitarias deben ejecutarse rápidamente.
  3. Nombres descriptivos: Los nombres de las pruebas deben describir lo que prueban.
  4. Una aserción por prueba: Idealmente, cada prueba debería verificar una sola cosa.
  5. Evitar lógica compleja: No utilizar condicionales o bucles en las pruebas.
  6. Probar casos límite: Incluir casos extremos y valores de borde.
  7. Automatizar: Integrar las pruebas en el proceso de CI/CD.
  8. Revisar la cobertura: Asegurar una buena cobertura de código con las pruebas.

Resumen

Las pruebas unitarias son fundamentales para garantizar la calidad del código en proyectos de ciencia de datos. Con pytest, tenemos un framework potente y flexible para escribir, organizar y ejecutar pruebas de manera eficiente. Al implementar pruebas unitarias en nuestros proyectos, podemos detectar problemas temprano, facilitar el mantenimiento y mejorar la calidad general de nuestro código.

Referencias

Jose R. Zapata