Módulo 2: Unit Tests con Claude Code

Parametrize: Múltiples Escenarios con Un Solo Test

Parametrize: Múltiples Escenarios con Un Solo Test

Descripción de la cápsula

En la cápsula 03 aprendiste el patrón AAA y las fixtures. Escribiste tests claros, uno por escenario. Pero hay un problema que aparece rápido: cuando una función tiene muchos casos válidos — por ejemplo, una conversión de temperaturas que funciona para 0°C, 100°C, -40°C, etc. — terminas con 5, 10 o más tests que son idénticos en estructura y solo cambian los valores de entrada y salida esperada. Eso es código repetitivo, difícil de mantener y propenso a copy-paste errors.

@pytest.mark.parametrize resuelve ese problema: permite ejecutar un solo test con múltiples conjuntos de datos. Misma lógica de assert, diferentes inputs y outputs. Esta cápsula te enseña cuándo parametrizar, cómo hacerlo bien, y cómo usar Claude Code para refactorizar tests repetitivos en suites más limpias y mantenibles.

El skill es crucial cuando trabajas con AI: Claude Code tiende a generar muchos tests similares. Saber cuándo colapsarlos con parametrize — y cómo pedírselo — te da control sobre la calidad y mantenibilidad de tu suite.


El Problema: Tests Repetitivos

Cinco tests que hacen lo mismo

Imagina que tienes una función para convertir Celsius a Fahrenheit:

# temperature.py
def celsius_to_fahrenheit(celsius: float) -> float:
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

Necesitas testear varios valores: 0 → 32, 100 → 212, -40 → -40, etc. Sin parametrize, escribirías:

# test_temperature.py
import pytest
from temperature import celsius_to_fahrenheit


def test_celsius_0_equals_fahrenheit_32():
    assert celsius_to_fahrenheit(0) == 32


def test_celsius_100_equals_fahrenheit_212():
    assert celsius_to_fahrenheit(100) == 212


def test_celsius_minus_40_equals_minus_40():
    assert celsius_to_fahrenheit(-40) == -40


def test_celsius_37_equals_body_temp():
    assert celsius_to_fahrenheit(37) == 98.6


def test_celsius_minus_273_15_freezing_point():
    assert celsius_to_fahrenheit(-273.15) == pytest.approx(-459.67, rel=1e-3)

Problemas con este enfoque:

  • ❌ 5 funciones que repiten la misma estructura: assert celsius_to_fahrenheit(X) == Y
  • ❌ Agregar un nuevo caso implica copy-paste y riesgo de error
  • ❌ Si cambias el nombre de la función, hay que actualizar 5 sitios
  • ❌ El código no escala: 20 casos = 20 funciones casi idénticas

La solución: un solo test, múltiples datos

Con @pytest.mark.parametrize, colapsas todo en un solo test:

# test_temperature.py
import pytest
from temperature import celsius_to_fahrenheit


@pytest.mark.parametrize("celsius,expected", [
    (0, 32),
    (100, 212),
    (-40, -40),
    (37, 98.6),
    (-273.15, pytest.approx(-459.67, rel=1e-3)),
])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Ventajas:

  • ✅ Un solo test, 5 ejecuciones (pytest cuenta cada fila como un caso)
  • ✅ Agregar un caso = agregar una tupla a la lista
  • ✅ Mantenimiento centralizado: un lugar para la lógica del assert
  • ✅ Fácil de leer: la tabla de datos documenta los casos cubiertos

Al ejecutar pytest -v, verás algo como:

test_celsius_to_fahrenheit[0-32] PASSED
test_celsius_to_fahrenheit[100-212] PASSED
test_celsius_to_fahrenheit[-40--40] PASSED
test_celsius_to_fahrenheit[37-98.6] PASSED
test_celsius_to_fahrenheit[-273.15-approx] PASSED

Sintaxis Básica de Parametrize

Estructura mínima

@pytest.mark.parametrize("arg1,arg2", [
    (valor1_a, valor2_a),
    (valor1_b, valor2_b),
])
def test_algo(arg1, arg2):
    assert funcion(arg1) == arg2
  • Primer argumento: string con nombres de parámetros separados por coma
  • Segundo argumento: lista de tuplas; cada tupla es un caso de prueba
  • Función: recibe esos parámetros y hace el assert

Ejemplo con un solo parámetro

@pytest.mark.parametrize("n", [0, 1, 2, -1, 100])
def test_abs_non_negative(n):
    assert abs(n) >= 0

Ejemplo con dos parámetros

@pytest.mark.parametrize("input_val,expected", [
    (0, 32),
    (100, 212),
    (-40, -40),
])
def test_celsius_to_fahrenheit(input_val, expected):
    assert celsius_to_fahrenheit(input_val) == expected

Ejemplo con tres o más parámetros

# math_utils.py
def clamp(value: float, low: float, high: float) -> float:
    """Clamp value between low and high."""
    return max(low, min(high, value))


# test_math_utils.py
@pytest.mark.parametrize("value,low,high,expected", [
    (50, 0, 100, 50),
    (-10, 0, 100, 0),
    (150, 0, 100, 100),
    (0, 0, 100, 0),
    (100, 0, 100, 100),
])
def test_clamp(value, low, high, expected):
    assert clamp(value, low, high) == expected

Parámetros Múltiples y pytest.param

Usar pytest.param para IDs descriptivos

Por defecto, pytest genera IDs automáticos a partir de los valores. Con valores largos o complejos, los IDs pueden ser ilegibles. pytest.param permite dar nombres explícitos:

@pytest.mark.parametrize("celsius,expected", [
    pytest.param(0, 32, id="freezing_point"),
    pytest.param(100, 212, id="boiling_point"),
    pytest.param(-40, -40, id="celsius_fahrenheit_intersection"),
    pytest.param(37, 98.6, id="body_temperature"),
])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Salida con pytest -v:

test_celsius_to_fahrenheit[freezing_point] PASSED
test_celsius_to_fahrenheit[boiling_point] PASSED
test_celsius_to_fahrenheit[celsius_fahrenheit_intersection] PASSED
test_celsius_to_fahrenheit[body_temperature] PASSED

Combinar parametrize con marks

Puedes marcar casos individuales como xfail, skip o slow:

# math_utils.py
def square(x: float) -> float:
    return x * x


# test_math_utils.py
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (2, 4),
    pytest.param(-1, 1, marks=pytest.mark.xfail(reason="negative input not yet supported")),
    pytest.param(0, 0, marks=pytest.mark.skip(reason="edge case, implementar luego")),
])
def test_square(value, expected):
    assert square(value) == expected
  • pytest.mark.xfail: el test se espera que falle (documenta comportamiento pendiente)
  • pytest.mark.skip: el test no se ejecuta

Varias variables con pytest.param

# math_utils.py
def add(a: float, b: float) -> float:
    return a + b


# test_math_utils.py
@pytest.mark.parametrize("a,b,expected", [
    pytest.param(1, 1, 2, id="positive"),
    pytest.param(-1, -1, -2, id="negative"),
    pytest.param(0, 0, 0, id="zeros"),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

Cuándo Parametrizar vs Cuándo Escribir Tests Separados

Regla práctica

SituaciónUsarMotivo
Misma lógica, diferentes datosParametrizeData-driven: un assert, N conjuntos de datos
Diferente lógica o diferente assertTests separadosComportamientos distintos requieren tests distintos
Solo cambia input/outputParametrizeCaso ideal para parametrize
Cambia el tipo de verificaciónTests separadosEj: un test verifica retorno, otro verifica excepción

Parametrize: misma lógica, diferentes datos

# ✅ Correcto: todos verifican "conversión correcta"
@pytest.mark.parametrize("celsius,expected", [(0, 32), (100, 212), (-40, -40)])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Tests separados: comportamientos diferentes

# ✅ Correcto: comportamientos distintos
def test_celsius_to_fahrenheit_valid_input():
    assert celsius_to_fahrenheit(0) == 32


def test_celsius_to_fahrenheit_rejects_none():
    with pytest.raises(TypeError):
        celsius_to_fahrenheit(None)


def test_celsius_to_fahrenheit_rejects_string():
    with pytest.raises(TypeError):
        celsius_to_fahrenheit("25")

No parametrizarías el happy path con los casos de excepción: el assert es distinto (assert resultado vs pytest.raises).

Criterio rápido

  • ¿Solo cambian los números/strings y el resultado esperado? → Parametrize
  • ¿Cambia qué estás verificando (retorno vs excepción vs side effect)? → Tests separados

Claude Code y Parametrize

El patrón típico de Claude Code

Cuando pides "escribe tests para [función]" con muchos casos, Claude Code suele generar algo así:

def test_validate_age_18():
    assert validate_age(18) == True

def test_validate_age_21():
    assert validate_age(21) == True

def test_validate_age_0():
    assert validate_age(0) == False

def test_validate_age_negative():
    assert validate_age(-1) == False

def test_validate_age_150():
    assert validate_age(150) == True

def test_validate_age_151():
    assert validate_age(151) == False

Seis funciones con la misma estructura. Funciona, pero es verbose.

Cómo pedir el refactor

Puedes pedir explícitamente:

Refactoriza estos tests repetitivos usando @pytest.mark.parametrize.
Mantén la misma cobertura pero reduce la duplicación.
Usa pytest.param con ids descriptivos donde ayude a leer el output.

O más específico:

Convierte los tests de validate_age a un único test parametrizado.
Incluye: 18, 21 (válidos), 0, -1 (inválidos), 150 (límite), 151 (fuera de rango).
Usa ids como "adult_18", "adult_21", "minor_zero", etc.

Cómo se ve el resultado parametrizado en pytest -v

test_validate_age[adult_18] PASSED
test_validate_age[adult_21] PASSED
test_validate_age[minor_zero] PASSED
test_validate_age[negative_invalid] PASSED
test_validate_age[max_valid_150] PASSED
test_validate_age[over_max_151] PASSED

Cada caso aparece como un test independiente en el reporte, pero el código es un solo test parametrizado.


Parametrize para Edge Cases y Boundary Testing

Por qué parametrize encaja con edge cases

Los edge cases suelen ser "misma función, distintos valores límite". Parametrize permite listarlos de forma clara y añadir más sin duplicar lógica.

Ejemplo: valores límite y especiales

# validators.py
def is_valid_port(port: int) -> bool:
    """Check if port is in valid range 1-65535."""
    if not isinstance(port, int):
        raise TypeError("Port must be integer")
    return 1 <= port <= 65535


# test_validators.py
import sys
import pytest
from validators import is_valid_port


@pytest.mark.parametrize("port,expected", [
    (1, True),
    (65535, True),
    (8080, True),
    (0, False),
    (-1, False),
    (65536, False),
    (sys.maxsize, False),
])
def test_is_valid_port_boundaries(port, expected):
    assert is_valid_port(port) == expected


@pytest.mark.parametrize("invalid_input", [None, "80", 80.0, [], {}])
def test_is_valid_port_rejects_non_integer(invalid_input):
    with pytest.raises(TypeError, match="Port must be integer"):
        is_valid_port(invalid_input)

Observa: los casos "válidos/inválidos" por rango se parametrizan en un test; los casos de "tipo incorrecto" van en otro test (assert diferente).

Tabla de edge cases típicos

TipoEjemplos
Cero0
Uno1
Negativo-1
Vacío"", [], {}
Límite inferiorMIN, 0, 1
Límite superiorMAX, len-1
Fuera de rangoMAX+1, -1 para índice
NoneNone
Float vs int1.0 vs 1

Parametrize te deja definir una fila por caso y documenta el conjunto de edge cases de un vistazo.


Comparación: Parametrize vs Tests Separados

Antes (tests separados)

# math_utils.py: def add(a, b): return a + b

from math_utils import add


def test_add_positive():
    assert add(1, 1) == 2


def test_add_negative():
    assert add(-1, -1) == -2


def test_add_zero():
    assert add(0, 0) == 0


def test_add_mixed():
    assert add(5, -3) == 2
  • 4 funciones
  • Misma estructura repetida
  • Mantenimiento en 4 lugares

Después (parametrize)

import pytest
from math_utils import add


@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (-1, -1, -2),
    (0, 0, 0),
    (5, -3, 2),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
  • 1 función, 4 ejecuciones
  • Un solo lugar para el assert
  • Fácil añadir (100, -50, 50) u otros casos

Cuándo NO parametrizar

# ❌ Mezclar retorno normal con excepciones en el mismo parametrize
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (None, "error"),  # ¿Assert de excepción? Mezcla lógica
])
def test_something(value, expected):
    ???  # Un caso hace assert, otro pytest.raises. Confuso.

Mejor: un test parametrizado para el happy path y tests separados para cada tipo de error.


Conexión con Proyecto

En el proyecto de este módulo ("Unit test suite generada") trabajarás con un módulo data_utils.py que incluye validaciones, formateo y transformación de datos. Muchas funciones tienen múltiples casos válidos e inválidos que se prestan a parametrize:

  • Validadores: mismo assert, distintos inputs (válido/inválido)
  • Formateadores: mismo assert, distintos inputs y outputs esperados
  • Transformadores: misma lógica de verificación, distintos pares input/output

Usar parametrize te permitirá:

  • ✅ Reducir duplicación cuando Claude Code genere muchos tests similares
  • ✅ Aumentar cobertura de edge cases sin inflar el número de funciones
  • ✅ Mantener la suite legible y fácil de extender

En la cápsula 05 aprenderás a validar si los tests generados (parametrizados o no) son realmente útiles o triviales — el skill más crítico del módulo.


Troubleshooting

1. "ValueError: Could not resolve parametrize parameter"

Causa: El nombre del parámetro en el string no coincide con el nombre del argumento de la función.

# ❌ Incorrecto
@pytest.mark.parametrize("x,y", [(1, 2)])
def test_foo(a, b):  # a, b no existen en parametrize
    assert a + b == 3

# ✅ Correcto
@pytest.mark.parametrize("a,b", [(1, 2)])
def test_foo(a, b):
    assert a + b == 3

Solución: Los nombres en "a,b" deben coincidir exactamente con los argumentos de la función.


2. IDs ilegibles con valores complejos

Causa: pytest genera IDs automáticos a partir de repr() de los valores. Con listas, dicts o objetos, el ID puede ser muy largo.

# IDs feos: [1-2-3-4-5], [0--1]
@pytest.mark.parametrize("values,expected", [
    ([1, 2, 3, 4, 5], 15),
    ([0, -1], -1),
])

Solución: Usa pytest.param(..., id="nombre_descriptivo"):

@pytest.mark.parametrize("values,expected", [
    pytest.param([1, 2, 3, 4, 5], 15, id="positive_sum"),
    pytest.param([0, -1], -1, id="with_negative"),
])

3. Parametrize con fixtures: orden de evaluación

Causa: Si usas parametrize y fixtures juntos, pytest evalúa parametrize primero. Si la fixture depende del parámetro, puede haber confusión.

@pytest.fixture
def data(input_val):  # input_val viene de parametrize
    return process(input_val)

@pytest.mark.parametrize("input_val", [1, 2, 3])
def test_something(data):  # data usa input_val
    assert data > 0

Solución: Las fixtures pueden recibir parámetros de parametrize como argumentos. Asegúrate de que la fixture declare input_val y que el test use data (o lo que necesites). Si algo falla, revisa que no haya dependencias circulares entre fixtures y parámetros.


4. Mezclar tipos de assert en un solo parametrize

Causa: Un caso verifica retorno, otro verifica excepción. La lógica del test se complica.

# ❌ Confuso
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (None, None),  # ¿O debe lanzar? ¿Cómo se assert?
])
def test_parse(value, expected):
    ???

Solución: Separa en dos tests: uno parametrizado para el happy path y otro(s) para excepciones:

@pytest.mark.parametrize("value,expected", [(1, 1), (2, 2)])
def test_parse_valid(value, expected):
    assert parse(value) == expected

def test_parse_none_raises():
    with pytest.raises(ValueError):
        parse(None)

5. Comportamiento distinto en un parámetro hace el test frágil

Causa: Parametrizas casos que en realidad tienen reglas distintas (ej. positivos vs negativos con lógica diferente).

Solución: Si un subconjunto de casos tiene una regla distinta, divídelo en otro test parametrizado o en tests separados. Parametrize para "misma regla, distintos datos", no para mezclar comportamientos.


Ejercicios

Ejercicio 1: Parametrize básico

La función is_even(n) retorna True si n es par, False si es impar. Escribe un test parametrizado que cubra: 0, 2, 4, -2 (pares) y 1, 3, -1 (impares).

Ver solución
# math_utils.py
def is_even(n: int) -> bool:
    return n % 2 == 0


# test_math_utils.py
import pytest
from math_utils import is_even


@pytest.mark.parametrize("n,expected", [
    (0, True),
    (2, True),
    (4, True),
    (-2, True),
    (1, False),
    (3, False),
    (-1, False),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

Ejercicio 2: pytest.param con IDs

Refactoriza el test anterior usando pytest.param con IDs descriptivos como "zero", "positive_even", "negative_odd".

Ver solución
@pytest.mark.parametrize("n,expected", [
    pytest.param(0, True, id="zero"),
    pytest.param(2, True, id="positive_even"),
    pytest.param(4, True, id="positive_even_large"),
    pytest.param(-2, True, id="negative_even"),
    pytest.param(1, False, id="positive_odd"),
    pytest.param(3, False, id="positive_odd_large"),
    pytest.param(-1, False, id="negative_odd"),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

Ejercicio 3: Cuándo parametrizar

Tienes una función divide(a, b) que retorna a / b. ¿Parametrizarías en un solo test estos casos?

  • (10, 2) → 5
  • (0, 5) → 0
  • (5, 0) → lanza ZeroDivisionError

Justifica y escribe los tests adecuados.

Ver solución

No todos en el mismo parametrize. Los dos primeros comparten assert (assert divide(a, b) == expected). El tercero verifica excepción (assert distinto).

Solución:

# math_utils.py
def divide(a: float, b: float) -> float:
    return a / b


# test_math_utils.py
@pytest.mark.parametrize("a,b,expected", [
    (10, 2, 5),
    (0, 5, 0),
])
def test_divide_valid(a, b, expected):
    assert divide(a, b) == expected


def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        divide(5, 0)

Ejercicio 4: Edge cases con parametrize

La función safe_int(s: str) convierte string a int; retorna None si falla. Escribe un test parametrizado que cubra: "42", "0", "-1", " 10 " (con espacios), "", "abc", None.

Ver solución

None y los strings inválidos pueden requerir un assert distinto (ej. que retorne None). Si safe_int retorna None para todos los inválidos, se puede parametrizar todo:

# validators.py
def safe_int(s) -> int | None:
    if s is None:
        return None
    try:
        return int(str(s).strip())
    except (ValueError, TypeError):
        return None


# test_validators.py
import pytest
from validators import safe_int


@pytest.mark.parametrize("s,expected", [
    ("42", 42),
    ("0", 0),
    ("-1", -1),
    (" 10 ", 10),
])
def test_safe_int_valid(s, expected):
    assert safe_int(s) == expected


@pytest.mark.parametrize("s", ["", "abc", None, "12.34"])
def test_safe_int_invalid_returns_none(s):
    assert safe_int(s) is None

Si safe_int(None) lanzara excepción en vez de retornar None, ese caso iría en un test separado con pytest.raises.


Ejercicio 5: Refactor con Claude Code

Copia estos 6 tests en un archivo y pídele a Claude Code que los refactorice con parametrize:

def test_normalize_whitespace_single_space():
    assert normalize_whitespace("hello   world") == "hello world"

def test_normalize_whitespace_tabs():
    assert normalize_whitespace("hello\t\tworld") == "hello world"

def test_normalize_whitespace_empty():
    assert normalize_whitespace("") == ""

def test_normalize_whitespace_only_spaces():
    assert normalize_whitespace("   ") == ""

def test_normalize_whitespace_no_change():
    assert normalize_whitespace("hello world") == "hello world"

def test_normalize_whitespace_leading_trailing():
    assert normalize_whitespace("  hello  ") == "hello"

Compara el resultado con la solución sugerida abajo.

Ver solución
# text_utils.py
def normalize_whitespace(s: str) -> str:
    """Collapse multiple whitespace to single space, strip edges."""
    if not s:
        return ""
    return " ".join(s.split())


# test_text_utils.py
import pytest
from text_utils import normalize_whitespace


@pytest.mark.parametrize("input_val,expected", [
    ("hello   world", "hello world"),
    ("hello\t\tworld", "hello world"),
    ("", ""),
    ("   ", ""),
    ("hello world", "hello world"),
    ("  hello  ", "hello"),
])
def test_normalize_whitespace(input_val, expected):
    assert normalize_whitespace(input_val) == expected

Con IDs opcionales:

@pytest.mark.parametrize("input_val,expected", [
    pytest.param("hello   world", "hello world", id="multiple_spaces"),
    pytest.param("hello\t\tworld", "hello world", id="tabs"),
    pytest.param("", "", id="empty"),
    pytest.param("   ", "", id="only_spaces"),
    pytest.param("hello world", "hello world", id="no_change"),
    pytest.param("  hello  ", "hello", id="leading_trailing"),
])
def test_normalize_whitespace(input_val, expected):
    assert normalize_whitespace(input_val) == expected

Ejercicio 6: Tres parámetros y boundary testing

La función in_range(value, low, high) retorna True si low <= value <= high. Escribe un test parametrizado con tres parámetros que cubra: valor dentro, valor igual a low, valor igual a high, valor debajo de low, valor arriba de high.

Ver solución
# range_utils.py
def in_range(value: float, low: float, high: float) -> bool:
    return low <= value <= high


# test_range_utils.py
import pytest
from range_utils import in_range


@pytest.mark.parametrize("value,low,high,expected", [
    (50, 0, 100, True),
    (0, 0, 100, True),
    (100, 0, 100, True),
    (-1, 0, 100, False),
    (101, 0, 100, False),
])
def test_in_range(value, low, high, expected):
    assert in_range(value, low, high) == expected

Resumen

  • ✅ @pytest.mark.parametrize ejecuta un solo test con múltiples conjuntos de datos
  • ✅ Usa parametrize cuando la lógica es la misma y solo cambian input/output
  • ✅ Usa tests separados cuando cambia el tipo de verificación (retorno vs excepción)
  • ✅ pytest.param(..., id="nombre") mejora la legibilidad de los IDs en pytest -v
  • ✅ Puedes combinar parametrize con marks (xfail, skip) para casos especiales
  • ✅ Claude Code suele generar tests repetitivos; puedes pedirle refactor con parametrize
  • ✅ Parametrize es muy útil para edge cases y boundary testing
  • ✅ La regla: si solo cambian datos → parametrize; si cambia lógica o assert → tests separados

Recursos Adicionales

  1. pytest: Parametrize — Documentación oficial — Referencia completa de sintaxis y uso
  2. pytest.param — Documentación — Uso de pytest.param para IDs y marks
  3. Data-driven testing with pytest (Real Python) — Ejemplos prácticos y buenas prácticas
  4. Parametrize fixtures (pytest docs) — Combinar parametrize con fixtures
  5. Test design: Equivalence partitioning and boundary value analysis — Fundamentos de diseño de tests para edge cases
  6. Python Testing with pytest (Pragmatic Programmers) — Libro de referencia sobre pytest y parametrize

Módulo 2, Cápsula 04 — Testing with Claude Code Guide Un test, muchos datos: parametrize para cobertura sin duplicación