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ón | Usar | Motivo |
|---|---|---|
| Misma lógica, diferentes datos | Parametrize | Data-driven: un assert, N conjuntos de datos |
| Diferente lógica o diferente assert | Tests separados | Comportamientos distintos requieren tests distintos |
| Solo cambia input/output | Parametrize | Caso ideal para parametrize |
| Cambia el tipo de verificación | Tests separados | Ej: 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
| Tipo | Ejemplos |
|---|---|
| Cero | 0 |
| Uno | 1 |
| Negativo | -1 |
| Vacío | "", [], {} |
| Límite inferior | MIN, 0, 1 |
| Límite superior | MAX, len-1 |
| Fuera de rango | MAX+1, -1 para índice |
| None | None |
| Float vs int | 1.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.parametrizeejecuta 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 enpytest -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
- pytest: Parametrize — Documentación oficial — Referencia completa de sintaxis y uso
- pytest.param — Documentación — Uso de
pytest.parampara IDs y marks - Data-driven testing with pytest (Real Python) — Ejemplos prácticos y buenas prácticas
- Parametrize fixtures (pytest docs) — Combinar parametrize con fixtures
- Test design: Equivalence partitioning and boundary value analysis — Fundamentos de diseño de tests para edge cases
- 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