Por Jose R. Zapata
Ultima actualización: 27/Mar/2025
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?
- Detectan errores temprano: Permiten identificar problemas antes de que lleguen a producción.
- Facilitan los cambios: Proveen confianza para modificar código existente.
- Documentan el código: Las pruebas describen cómo debe funcionar el código.
- Mejoran el diseño: Escribir código testeable fomenta mejores prácticas de diseño.
- 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:
- Se debe crear un directorio
tests
en el mismo nivel quesrc
. - Crear archivo
__init__.py
vacio en cada uno de los directorios desrc
ytests
, con el fin de que puedan ser tratados como paquetes y las pruebas puedan ser importadas. - 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
- Pruebas independientes: Cada prueba debe poder ejecutarse por sí sola.
- Pruebas rápidas: Las pruebas unitarias deben ejecutarse rápidamente.
- Nombres descriptivos: Los nombres de las pruebas deben describir lo que prueban.
- Una aserción por prueba: Idealmente, cada prueba debería verificar una sola cosa.
- Evitar lógica compleja: No utilizar condicionales o bucles en las pruebas.
- Probar casos límite: Incluir casos extremos y valores de borde.
- Automatizar: Integrar las pruebas en el proceso de CI/CD.
- 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.