14. Parametrización para expresar múltiples ejemplos del mismo comportamiento

14.1 Objetivo del tema

En este tema aprenderemos a usar pytest.mark.parametrize para expresar varios ejemplos del mismo comportamiento sin duplicar estructura de prueba.

La parametrización es muy útil en TDD cuando distintos datos ejercitan la misma regla. Permite agregar ejemplos rápidamente y mejora la lectura cuando se usa con criterio.

Objetivo práctico: refactorizar pruebas repetidas en pruebas parametrizadas claras, manteniendo el ciclo rojo, verde y refactor.

14.2 Cuándo conviene parametrizar

Conviene parametrizar cuando varias pruebas tienen la misma estructura y solo cambian los datos de entrada y el resultado esperado.

Por ejemplo, una función de calificaciones tiene muchos casos con la misma forma:

assert obtener_calificacion(95) == "A"
assert obtener_calificacion(85) == "B"
assert obtener_calificacion(75) == "C"

14.3 Pruebas repetidas

Sin parametrización, podríamos escribir:

Archivo de ejemplo: tests/test_calificaciones.py

from academico.calificaciones import obtener_calificacion


def test_devuelve_a_para_puntaje_noventa_o_mas():
    assert obtener_calificacion(95) == "A"


def test_devuelve_b_para_puntaje_ochenta_o_mas():
    assert obtener_calificacion(85) == "B"


def test_devuelve_c_para_puntaje_setenta_o_mas():
    assert obtener_calificacion(75) == "C"

Las pruebas son claras, pero repiten la misma estructura.

14.4 Primera parametrización

La versión parametrizada concentra los ejemplos en una tabla:

Archivo a modificar: tests/test_calificaciones.py

import pytest

from academico.calificaciones import obtener_calificacion


@pytest.mark.parametrize(
    "puntaje, esperado",
    [
        (95, "A"),
        (85, "B"),
        (75, "C"),
    ],
)
def test_obtener_calificacion_segun_puntaje(puntaje, esperado):
    assert obtener_calificacion(puntaje) == esperado

Cada tupla genera un caso de prueba independiente.

14.5 Ejecutar pruebas parametrizadas

Ejecutamos igual que siempre:

python -m pytest

pytest ejecutará la función una vez por cada fila de datos.

14.6 Agregar un nuevo ejemplo

Para agregar un caso, sumamos otra fila:

Archivo a modificar: tests/test_calificaciones.py

@pytest.mark.parametrize(
    "puntaje, esperado",
    [
        (95, "A"),
        (85, "B"),
        (75, "C"),
        (60, "D"),
    ],
)
def test_obtener_calificacion_segun_puntaje(puntaje, esperado):
    assert obtener_calificacion(puntaje) == esperado

Esto facilita ampliar la cobertura de ejemplos sin repetir funciones completas.

14.7 IDs descriptivos

Podemos usar pytest.param para agregar nombres descriptivos a cada caso:

@pytest.mark.parametrize(
    "puntaje, esperado",
    [
        pytest.param(95, "A", id="calificacion-a"),
        pytest.param(85, "B", id="calificacion-b"),
        pytest.param(75, "C", id="calificacion-c"),
        pytest.param(60, "D", id="calificacion-d"),
    ],
)
def test_obtener_calificacion_segun_puntaje(puntaje, esperado):
    assert obtener_calificacion(puntaje) == esperado

Los IDs hacen más claro el reporte cuando un caso falla.

14.8 Parametrizar casos borde

Los límites son excelentes candidatos para parametrización:

@pytest.mark.parametrize(
    "puntaje, esperado",
    [
        pytest.param(90, "A", id="limite-a"),
        pytest.param(89, "B", id="debajo-de-a"),
        pytest.param(80, "B", id="limite-b"),
        pytest.param(79, "C", id="debajo-de-b"),
        pytest.param(70, "C", id="limite-c"),
        pytest.param(69, "D", id="debajo-de-c"),
    ],
)
def test_obtener_calificacion_respeta_limites(puntaje, esperado):
    assert obtener_calificacion(puntaje) == esperado

Esta tabla documenta con precisión los bordes de cada rango.

14.9 Parametrizar valores inválidos

También podemos parametrizar excepciones esperadas para valores fuera de rango:

@pytest.mark.parametrize("puntaje", [-1, 101])
def test_obtener_calificacion_rechaza_puntajes_fuera_de_rango(puntaje):
    with pytest.raises(ValueError):
        obtener_calificacion(puntaje)

Ambos casos verifican la misma regla: el puntaje debe estar entre 0 y 100.

14.10 Parametrizar tipos inválidos

Podemos agrupar valores no numéricos:

@pytest.mark.parametrize("puntaje", ["noventa", None, [], {}])
def test_obtener_calificacion_rechaza_puntajes_no_numericos(puntaje):
    with pytest.raises(TypeError):
        obtener_calificacion(puntaje)

Todos estos datos deberían provocar la misma excepción.

14.11 Parametrizar excepciones diferentes

Si distintos datos esperan distintas excepciones, también puede parametrizarse:

@pytest.mark.parametrize(
    "puntaje, excepcion",
    [
        (-1, ValueError),
        (101, ValueError),
        ("noventa", TypeError),
        (None, TypeError),
    ],
)
def test_obtener_calificacion_rechaza_puntajes_invalidos(puntaje, excepcion):
    with pytest.raises(excepcion):
        obtener_calificacion(puntaje)

Esta forma es compacta, pero puede ser menos explícita que separar reglas distintas en pruebas distintas.

14.12 Cuándo no parametrizar

No conviene parametrizar si los casos representan reglas distintas y necesitan nombres separados para entenderse mejor.

Por ejemplo, estas dos reglas pueden merecer pruebas separadas:

  • El puntaje fuera de rango lanza ValueError.
  • El puntaje no numérico lanza TypeError.

La claridad vale más que reducir líneas.

14.13 Parametrización y TDD

En TDD suele ser útil empezar con una prueba concreta y luego parametrizar cuando aparecen varios ejemplos similares.

Un flujo posible:

  • Escribir una prueba simple.
  • Hacerla pasar.
  • Agregar otro ejemplo similar.
  • Cuando la repetición sea clara, refactorizar a parametrización.

14.14 Caso práctico: descuentos

Supongamos una función que aplica descuentos porcentuales:

Archivo a crear: tests/test_descuentos.py

import pytest

from tienda.descuentos import aplicar_descuento


@pytest.mark.parametrize(
    "precio, porcentaje, esperado",
    [
        (100, 10, 90),
        (200, 25, 150),
        (80, 0, 80),
    ],
)
def test_aplicar_descuento(precio, porcentaje, esperado):
    assert aplicar_descuento(precio, porcentaje) == esperado

Los tres casos prueban la misma regla con datos distintos.

14.15 Implementación para descuentos

Una implementación suficiente sería:

Archivo a crear: src/tienda/descuentos.py

def aplicar_descuento(precio, porcentaje):
    descuento = precio * porcentaje / 100
    return precio - descuento

Ejecutamos:

python -m pytest

14.16 Mejorar IDs del caso práctico

Podemos mejorar los casos usando pytest.param:

@pytest.mark.parametrize(
    "precio, porcentaje, esperado",
    [
        pytest.param(100, 10, 90, id="diez-por-ciento"),
        pytest.param(200, 25, 150, id="veinticinco-por-ciento"),
        pytest.param(80, 0, 80, id="sin-descuento"),
    ],
)
def test_aplicar_descuento(precio, porcentaje, esperado):
    assert aplicar_descuento(precio, porcentaje) == esperado

Si un caso falla, el nombre del caso ayuda a diagnosticar.

14.17 Mantener tablas pequeñas

Una tabla parametrizada demasiado grande puede ser difícil de leer. Si tiene muchos casos, conviene agruparlos por regla o crear pruebas parametrizadas separadas.

Por ejemplo, una prueba para descuentos válidos y otra para porcentajes inválidos puede ser más clara que una sola tabla con todo mezclado.

14.18 Errores frecuentes

  • Parametrizar reglas distintas: puede ocultar la intención de cada prueba.
  • No usar IDs en casos complejos: dificulta leer fallos.
  • Crear tablas enormes: vuelve la prueba difícil de mantener.
  • Parametrizar antes de entender la regla: puede complicar el ciclo rojo, verde y refactor.
  • Usar nombres genéricos: una prueba parametrizada también necesita un nombre claro.

14.19 Ejercicio propuesto

Refactoriza estas pruebas a una prueba parametrizada:

def test_calcular_puntos_para_100():
    assert calcular_puntos(100) == 10


def test_calcular_puntos_para_50():
    assert calcular_puntos(50) == 5


def test_calcular_puntos_para_9():
    assert calcular_puntos(9) == 0

Agrega IDs descriptivos y ejecuta python -m pytest.

14.20 Lista de verificación

Antes de continuar, verifica lo siguiente:

  • Parametrizas solo ejemplos del mismo comportamiento.
  • El nombre de la prueba describe la regla general.
  • Los datos de la tabla son fáciles de entender.
  • Usas IDs cuando ayudan a leer el reporte.
  • Separaste pruebas de reglas distintas.
  • Ejecutaste python -m pytest después del refactor.

14.21 Conclusión

En este tema usamos parametrización para expresar múltiples ejemplos del mismo comportamiento. Bien aplicada, reduce duplicación y permite agregar casos con poco esfuerzo.

En el próximo tema aplicaremos TDD sobre colecciones: listas, diccionarios y transformaciones de datos.