parametrize — un test, muchos casos
Reemplazar tests copiados y pegados y bucles dentro de un test con @pytest.mark.parametrize. IDs de test, ids= y pytest.param, apilar decoradores, valores y errores en una sola tabla, y los errores comunes.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes una pequeña función que convierte la puntuación de un examen en una calificación, grading.py:
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C FProbarla adecuadamente significa comprobar muchas puntuaciones, especialmente los límites: 80 y 79, 60 y 59, 40 y 39. La primera forma en que cualquiera escribe eso es copiando un test y cambiando dos valores:
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sFunciona, pero no escala. Tres funciones dicen lo mismo («esta puntuación da esta calificación») y solo dos valores difieren entre ellas. Diez casos más significan diez copias más que mantener sincronizadas.
Así que la siguiente idea es una lista de casos y un bucle dentro de un test. Dos de las calificaciones esperadas a continuación son incorrectas a propósito: 79 debería ser B y 40 debería ser C:
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sEse informe es peor de lo que parece. Había dos casos incorrectos y solo te enteraste de uno: el primer assert que falla termina el test, por lo que el caso de 40 nunca se ejecutó. El informe no indica qué puntuación falló; tienes que deducir que 'B' == 'C' provino de 79. Y "1 failed" cuenta cinco casos como un solo hecho. Corriges el primero, ejecutas de nuevo, y solo entonces te encuentras con el segundo.
Lo que buscas es la brevedad del bucle con el detalle de informe de las copias: una función, muchos tests independientes.
Al terminar este capítulo podrás
- Ejecutar una función de test sobre muchos casos con
@pytest.mark.parametrize - Pasar varios valores por caso y leer los IDs de test que genera pytest
- Nombrar los casos con
ids=y conpytest.param(..., id=...) - Marcar un solo caso como
xfailoskipsin tocar los demás - Combinar dos decoradores parametrize para obtener todas las combinaciones de sus valores
- Mezclar casos que devuelven un valor y que lanzan un error en una sola tabla
- Reconocer los errores comunes de parametrize a partir de sus mensajes de error
Requisitos previos: comparar floats con `pytest.approx`.
Antes de escribir el test
Parametrize es una forma de escribir una tabla. Así que el trabajo antes de escribir cualquier código de test consiste en construir esa tabla y decidir si una tabla es realmente la estructura adecuada.
1. Establece el contrato. grade(score) promete: entra una puntuación entera de 0 a 100; sale una de "A", "B", "C" o "F"; cualquier valor fuera de 0–100 lanza un ValueError cuyo mensaje menciona la puntuación. No tiene efectos secundarios —ni archivos, ni impresión por pantalla—, así que no hay nada que preparar ni limpiar. Eso es lo que la hace encajar a la perfección: cada caso es «estas entradas, ese resultado».
2. Comprueba tu configuración. Necesitas el entorno virtual activo, pytest instalado y grading.py importable desde el archivo de test (en estos ejemplos se encuentra junto a los tests en la misma carpeta). Nada más: parametrize viene integrado en pytest y contextlib está en la biblioteca estándar.
3. Planifica los casos. Los límites son lo más importante, porque ahí es donde ocurren los errores de >= frente a >. Cada regla recibe el valor a cada lado de su frontera:
| Caso | Entrada | Esperado | |---|---|---| | límite superior del rango | 100 | "A" | | A más baja | 80 | "A" | | B más alta | 79 | "B" | | B más baja | 60 | "B" | | C más alta | 59 | "C" | | C más baja | 40 | "C" | | F más alta | 39 | "F" | | límite inferior del rango | 0 | "F" | | justo por debajo del rango | -1 | ValueError | | justo por encima del rango | 101 | ValueError |
Cada fila se convierte en un caso. La columna "Caso" no es decorativa: se convertirá más adelante en el ID del test, de modo que un fallo te dirá qué regla se rompió.
4. Decide qué no probar. No todas las puntuaciones de 0 a 100: 95 y 85 prueban la misma regla que 80 y añaden tiempo de ejecución sin aportar información. Un valor por cada lado de cada límite es suficiente. No pruebes los operadores de comparación propios de Python y no pongas dos comportamientos diferentes bajo un mismo test solo porque caben en una tabla; si las filas necesitan aserciones distintas, corresponden a tests distintos.
El resto del capítulo convierte esta tabla en código, herramienta por herramienta.
Un argumento, muchos valores
El decorador toma el nombre de un argumento y una lista de valores para él. Pytest crea un test por cada valor y pasa el valor bajo ese nombre:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================Una función, tres tests, cada uno con su propia línea y su propio nombre. La parte entre corchetes es el ID del test, construido a partir del valor. score no es una fixture y no tiene valor por defecto: el nombre en la cadena del decorador simplemente debe coincidir con un parámetro de la función.
Varios argumentos por caso
La mayoría de los casos necesitan una entrada y un resultado esperado. Nombra ambos, separados por una coma, y proporciona cada caso como una tupla en el mismo orden. Aquí está el bucle de nuevo, con las mismas dos expectativas incorrectas:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sCompara esto con el bucle. Cada caso se ejecutó. Ambos fallos se reportan, cada uno bajo un ID — test_grade[79-C] — que identifica el caso. El conteo es honesto: 2 failed, 3 passed. (Con el traceback largo por defecto, pytest también imprime los argumentos de cada caso fallido, score = 79, expected = 'C'.)
Los nombres se pueden escribir como una sola cadena, "score,expected" o "score, expected" —se permiten espacios después de las comas—, o como una lista de cadenas, ["score", "expected"]. Ambas formas producen exactamente los mismos tests. La cadena es la forma habitual; la lista resulta práctica cuando los nombres se generan mediante código.
Cómo se generan los IDs
Para números, cadenas, booleanos y None, pytest coloca el propio valor en el ID y une los valores de un caso con -. Para cualquier otra cosa —una lista, un diccionario, un objeto—, ni lo intenta. Utiliza el nombre del argumento más la posición del caso:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2 significa «value y expected del caso número 2, contando desde cero». Es único, pero no te dice nada. Cuando un caso así falla, tienes que contar hacia abajo en la lista para encontrarlo. Esa es la razón para nombrar los casos tú mismo.
Nombrar los casos: ids=
Pasa a ids= una lista de cadenas, una por cada caso, en el mismo orden:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================Ahora la salida indica por qué existe cada caso. La debilidad es que los nombres residen en una segunda lista, separados de sus casos: inserta un caso en medio, olvida su nombre, y todos los nombres posteriores quedarán desfasados por uno.
ids= también acepta una función. Pytest la llama una vez por cada valor, no por cada caso, y une los resultados con -:
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================Una función resulta adecuada para valores cuyo ID automático no sirve de nada, como los objetos. Si devuelve None para un valor, pytest recurre al ID automático para ese valor.
Un caso a la vez: pytest.param
pytest.param envuelve un único caso y te permite adjuntarle cosas: un id= situado justo al lado de sus valores y marks= que se aplican únicamente a ese caso.
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================El caso round-up registra una decisión que aún no se ha tomado —¿debería 59.5 redondearse hacia arriba a una B?— sin hacer fallar la ejecución, y los demás casos no se ven afectados. (xfail y skip tendrán su propio capítulo más adelante; por ahora, basta saber que se puede aplicar una marca a un solo caso.)
Como el ID es un nombre real, se puede ejecutar un solo caso por su cuenta: pytest "test_param_obj.py::test_grade_cases[perfect]". Ponlo entre comillas: la shell trata los corchetes de forma especial.
Dos decoradores: cada combinación
Apila dos decoradores parametrize y pytest ejecutará el test para cada combinación de sus valores: el producto cartesiano. Dos monedas por tres importes son seis tests:
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================El decorador más cercano a la función proporciona la primera parte del ID. Apila solo cuando las entradas sean realmente independientes: cada importe debe funcionar con cada moneda. Si solo ciertos pares tienen sentido, lista esos pares como tuplas en un solo decorador. Y vigila la multiplicación: tres decoradores de diez valores cada uno son mil tests.
Valores y errores en una sola tabla
A veces los casos se dividen en "devuelve esto" y "lanza aquello". Puedes convertir la propia expectativa en un parámetro y entrar en ella con with:
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontext es un context manager que no hace nada: with nullcontext("C") as expected simplemente establece expected = "C", de modo que los casos normales ejecutan el assert de forma habitual. En los casos de error, pytest.raises es el context manager: grade lanza la excepción dentro de él, el assert nunca llega a completarse y el test pasa porque llegó el error correcto. Si no se produce ningún error, obtienes el conocido DID NOT RAISE.
Esto resulta ordenado para una tabla pequeña. Si el cuerpo del test empieza a requerir sentencias if para distinguir los dos tipos de casos, divídelo en dos tests. Fíjate también en los IDs: expectation0 es nuevamente la regla de los objetos. El ejemplo completo soluciona eso con pytest.param.
Parametrizar una clase
Coloca el decorador en una clase de test y cada método de test dentro de ella recibirá el parámetro:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================Dos métodos por dos valores: cuatro tests. Cada método de la clase debe aceptar score, o la recolección fallará.
Un ejemplo completo
shipping.py:
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================Tres funciones, catorce tests y cada línea indica lo que comprobó. Vale la pena destacar tres cosas.
En primer lugar, los IDs en test_cost indican el motivo de cada caso: part-kg-rounds-up nombra la regla que se protege, algo que 1.2-local-70 no haría.
En segundo lugar, test_first_kg_is_flat apila decoradores porque el peso y la zona son realmente independientes. También compara la función consigo misma en lugar de con un número, por lo que resiste cambios en las tarifas.
En tercer lugar, pytest.param(..., id=...) convierte expectation2 en negative. La tabla de valores y errores ahora se lee como una especificación.
Cuando algo falla
function uses no argument 'scores' Un nombre en la cadena del decorador no coincide con ningún parámetro de la función: aquí "scores,expected" frente a def test_grade(score, expected). Falla en la recolección, antes de que se ejecute cualquier test:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found El caso opuesto: la función tiene un parámetro que el decorador nunca nombra, por lo que pytest busca una fixture con ese nombre y no encuentra ninguna. Añade el nombre a la cadena del decorador.
the number of names (2) ... must be equal to the number of values (1) Un caso tiene un número incorrecto de valores: por ejemplo, (40,) en una lista para "score,expected":
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)IDs como [score0] y un TypeError que menciona una tupla Con un solo nombre de argumento, cada valor en la lista se pasa entero. Si escribes tuplas ahí por costumbre, la función recibirá una tupla:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================Los IDs son la pista: un número simple habría dado [80], por lo que [score0] indica que el valor era otra cosa. Para un solo argumento, escribe [80, 95].
Un caso pasa solo y falla junto a otros Los valores de los parámetros se crean una sola vez, cuando el módulo se recolecta, y el mismo objeto se entrega a cada test que lo recibe. Si un test modifica un valor mutable —una lista, un diccionario—, el siguiente test verá el cambio:
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sPasa algo inmutable, como la tupla (), y construye un objeto nuevo dentro de cada test con cart = list(items). Así, cada test comenzará desde su propia lista vacía y ambos pasarán.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Al ejecutar pytest -v, ¿qué cuatro IDs de test aparecen y en qué orden?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[1-x], test_pair[2-y]
- Btest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Ctest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Dtest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
grade(80) y grade(95) devuelven "A", pero ambos tests fallan. ¿Por qué?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- ALos IDs `score0` y `score1` entran en conflicto con el nombre de la función
- BLos valores deben ser una tupla de tuplas, no una lista
- CCon un solo nombre, cada valor se pasa entero, por lo que `score` es la tupla `(80,)`, no `80`
- Dparametrize no se puede usar con un solo nombre de argumento
Dos expectativas aquí son incorrectas (79 debería ser B, 40 debería ser C). ¿Qué reporta pytest?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — una línea por caso
- B1 failed — solo para el primer caso incorrecto; el caso con 40 nunca se ejecuta
- C1 failed — para el caso con 40, el último incorrecto
- D3 failed, porque un solo fallo hace fallar todos los casos
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe password.py con una función check_password(text) que devuelva una lista de problemas: "too short" con menos de 8 caracteres, "no digit" si no tiene dígitos, "no letter" si no tiene letras. Una contraseña válida devuelve []. Cualquier valor que no sea un str lanza TypeError.
Luego escribe test_password.py con:
- Un test parametrizado de al menos seis casos, escrito con
pytest.paramy unid=que indique de qué trata cada caso ("seven-chars","digits-only", etcétera) - Dos decoradores apilados —tres contraseñas válidas por dos sufijos,
""y"!"— que comprueben que las seis devuelven[] - Una tabla que mezcle casos normales (
nullcontext) con casos deTypeError(pytest.raises);Noney el entero12345678son buenas entradas para el segundo tipo - Un caso marcado como
xfailpara una regla que aún no hayas escrito, como "no uppercase letter"
Ejecuta pytest -v y lee la lista de IDs como si otra persona los hubiera escrito. ¿Podrías deducir las reglas de las contraseñas únicamente a partir de los IDs? Si no es así, renombra los casos hasta que puedas.
Luego rompe una expectativa a propósito y ejecuta de nuevo. Comprueba que falle exactamente una línea, que su ID te indique qué caso es y que todos los demás casos se hayan ejecutado.
Solución
Comienza con el contrato, luego con la tabla. check_password toma un str y devuelve una lista de cada problema encontrado, en un orden fijo; algo que no sea un str lanza TypeError en lugar de devolver algo. password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================Por qué está escrito de esta manera:
- El par límite va primero.
eight-chars-okyseven-charsse sitúan a ambos lados de la regla de longitud, el lugar donde se notaría un descuido entre<y<=. Cada una de las demás reglas tiene un caso que rompe únicamente esa regla, por lo que un fallo apunta con precisión a una sola línea decheck_password. emptycomprueba el orden de la lista. Es el único caso que incumple las tres reglas, por lo que fija que los problemas se devuelvan en un orden establecido: algo de lo que dependerá quien llame a la función para mostrárselos a un usuario.- Los valores esperados son listas nuevas en cada caso, y el test solo las lee. Nada se muta, por lo que la trampa de objetos compartidos de «Cuando algo falla» no puede ocurrir aquí.
- El apilamiento encaja con
test_good_passwordsporque el sufijo y la contraseña son independientes: cualquier contraseña válida debe seguir siéndolo con o sin un!. Los IDsabcdef12-yabcdef12-!son legibles sin ayuda, por lo que no fue necesarioids=. - La tabla de errores usa IDs con
pytest.paramporque los objetos denullcontextypytest.raisesaparecerían de lo contrario comoexpectation0,expectation1.match="int"comprueba que el mensaje mencione el tipo incorrecto, no solo que haya ocurrido algúnTypeError. - El caso
xfailregistra una regla futura sin hacer fallar la ejecución. Cuando se añada la regla de mayúsculas, ese caso comenzará a pasar y pytest lo reportará comoXPASS: la señal para eliminar la marca.
Leer los IDs de arriba abajo te da las reglas: ocho caracteres, al menos un dígito, al menos una letra, solo cadenas, mayúsculas planificadas. Esa es la prueba de si los nombres son lo bastante buenos.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz