15. Parametrización de pruebas para aumentar cobertura sin duplicar código

15.1 Objetivo del tema

Cuando el reporte de cobertura muestra muchos caminos parecidos sin probar, es tentador copiar y pegar pruebas. Eso funciona al principio, pero el archivo de pruebas se vuelve repetitivo y difícil de mantener.

La parametrización permite ejecutar la misma prueba con distintos datos. Es una herramienta muy útil para cubrir casos normales, casos borde y variantes de reglas sin duplicar código.

Objetivo práctico: usar pytest.mark.parametrize para cubrir más comportamientos con pruebas compactas y legibles.

15.2 Crear una función con varios caminos

Crea el archivo src/tienda/tarifas.py:

def calcular_tarifa(zona, peso):
    if peso <= 0:
        raise ValueError("El peso debe ser mayor que cero")

    if zona == "local":
        if peso <= 1:
            return 1000
        return 1500

    if zona == "nacional":
        if peso <= 1:
            return 2500
        return 4000

    if zona == "internacional":
        if peso <= 1:
            return 8000
        return 12000

    raise ValueError("Zona inválida")

La función combina zonas, pesos, límites y errores. Es un buen caso para parametrizar.

15.3 Pruebas repetidas

Sin parametrización, podríamos escribir muchas pruebas parecidas:

from tienda.tarifas import calcular_tarifa


def test_tarifa_local_liviana():
    assert calcular_tarifa("local", 1) == 1000


def test_tarifa_local_pesada():
    assert calcular_tarifa("local", 2) == 1500


def test_tarifa_nacional_liviana():
    assert calcular_tarifa("nacional", 1) == 2500


def test_tarifa_nacional_pesada():
    assert calcular_tarifa("nacional", 2) == 4000

Las pruebas son claras, pero repiten la misma estructura: entrada, llamada y resultado esperado.

15.4 Primera parametrización

Podemos reemplazar esas pruebas por una sola prueba parametrizada:

import pytest

from tienda.tarifas import calcular_tarifa


@pytest.mark.parametrize(
    "zona, peso, esperado",
    [
        ("local", 1, 1000),
        ("local", 2, 1500),
        ("nacional", 1, 2500),
        ("nacional", 2, 4000),
    ],
)
def test_calcular_tarifa(zona, peso, esperado):
    assert calcular_tarifa(zona, peso) == esperado

Pytest ejecuta la misma función de prueba una vez por cada fila de datos.

15.5 Agregar casos sin duplicar

Para cubrir la zona internacional, solo agregamos más filas a la tabla:

@pytest.mark.parametrize(
    "zona, peso, esperado",
    [
        ("local", 1, 1000),
        ("local", 2, 1500),
        ("nacional", 1, 2500),
        ("nacional", 2, 4000),
        ("internacional", 1, 8000),
        ("internacional", 2, 12000),
    ],
)
def test_calcular_tarifa(zona, peso, esperado):
    assert calcular_tarifa(zona, peso) == esperado

La cobertura aumenta porque se ejecutan más ramas, pero el código de prueba sigue siendo compacto.

15.6 Casos borde en la tabla

Los límites de peso están en 1. Conviene probar justo en el límite y justo después:

@pytest.mark.parametrize(
    "zona, peso, esperado",
    [
        ("local", 1, 1000),
        ("local", 1.01, 1500),
        ("nacional", 1, 2500),
        ("nacional", 1.01, 4000),
        ("internacional", 1, 8000),
        ("internacional", 1.01, 12000),
    ],
)
def test_calcular_tarifa_limites_de_peso(zona, peso, esperado):
    assert calcular_tarifa(zona, peso) == esperado

Una tabla de casos bien elegida puede cubrir ramas y casos borde al mismo tiempo.

15.7 Parametrizar excepciones

También podemos parametrizar entradas inválidas:

@pytest.mark.parametrize(
    "zona, peso",
    [
        ("local", 0),
        ("local", -1),
        ("desconocida", 1),
    ],
)
def test_calcular_tarifa_rechaza_datos_invalidos(zona, peso):
    with pytest.raises(ValueError):
        calcular_tarifa(zona, peso)

Esta prueba cubre validaciones distintas usando una estructura común.

15.8 Separar casos válidos e inválidos

Conviene mantener separadas las pruebas que esperan un resultado de las que esperan una excepción.

Esta separación mejora la lectura:

  • Casos válidos: entrada y resultado esperado.
  • Casos inválidos: entrada y excepción esperada.

Mezclar ambas cosas en una sola parametrización suele volver la prueba más difícil de entender.

15.9 Usar ids descriptivos

Cuando una tabla tiene varios casos, los ids ayudan a entender qué caso falló:

@pytest.mark.parametrize(
    "zona, peso, esperado",
    [
        ("local", 1, 1000),
        ("local", 1.01, 1500),
        ("internacional", 1, 8000),
        ("internacional", 1.01, 12000),
    ],
    ids=[
        "local-liviano",
        "local-pesado",
        "internacional-liviano",
        "internacional-pesado",
    ],
)
def test_calcular_tarifa_con_ids(zona, peso, esperado):
    assert calcular_tarifa(zona, peso) == esperado

Si falla un caso, pytest muestra el identificador en el nombre de la prueba.

15.10 Medir cobertura

Ejecuta cobertura con ramas para ver el impacto de los casos parametrizados.

En Windows PowerShell:

$env:PYTHONPATH="src"
python -m pytest --cov=src --cov-branch --cov-report=term-missing

En Linux o macOS:

PYTHONPATH=src python -m pytest --cov=src --cov-branch --cov-report=term-missing

La lista de líneas y ramas faltantes debería reducirse al cubrir más combinaciones de zona y peso.

15.11 Evitar tablas enormes

Parametrizar no significa probar todas las combinaciones posibles. Una tabla enorme puede volverse tan difícil de mantener como muchas pruebas duplicadas.

Elige casos que representen:

  • Cada rama importante.
  • Valores límite.
  • Entradas inválidas relevantes.
  • Reglas de negocio que podrían romperse.

15.12 Cuándo no parametrizar

No toda repetición debe convertirse en una tabla. A veces es mejor tener pruebas separadas si cada caso necesita una explicación propia o una preparación muy distinta.

Como regla práctica: parametriza cuando los casos comparten la misma intención y solo cambian los datos. Si cambia la idea de la prueba, probablemente convenga escribir otra prueba.

15.13 Errores frecuentes

  • Parametrizar casos sin relación: la tabla debe contar una misma historia con distintos datos.
  • Ocultar reglas importantes: usa nombres, ids o separación de pruebas para mantener claridad.
  • Mezclar resultados y excepciones sin necesidad: suele ser más claro separarlos.
  • Agregar combinaciones mecánicas: más filas no siempre significan mejores pruebas.

15.14 Conclusión

En este tema usamos pytest.mark.parametrize para aumentar cobertura sin duplicar código. La clave es elegir casos que representen ramas, límites y reglas importantes.

En el próximo tema vamos a configurar source, omit, include y branch en pyproject.toml.