Diseño de tests y TDD — qué hace que un test sea bueno
Planificar un test antes de escribirlo, Arrange-Act-Assert, probar el comportamiento y no la implementación, red-green-refactor, property-based testing con Hypothesis, las causas y soluciones de los tests flaky, y un test de regresión para cada bug.
¿Cómo quieres aprender?
El problema que vamos a resolver
Ya conoces todas las herramientas que este curso quería enseñarte: assert, raises, parametrize, fixtures, mocks, marcadores, configuración, cobertura, plugins. Y es perfectamente posible usarlas todas y, aun así, acabar con tests que hacen más daño que bien.
Aquí tienes un pequeño carrito de la compra, con dos tests que pasan:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sAhora alguien mejora el carrito. Añadir el mismo artículo dos veces debería fusionarse en una sola línea, así que la lista pasa a ser un diccionario indexado por nombre. El carrito sigue añadiendo cosas y sigue sumándolas bien: nada que un cliente pudiera notar ha cambiado:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sUn test en rojo, y ningún bug. El test no comprobaba lo que el carrito hace; comprobaba cómo estaba construido. Un test así falla cuando mejoras el código y se queda callado cuando lo rompes: justo lo contrario de su trabajo.
Este último capítulo no trata de otra herramienta. Trata de criterio: qué aspecto tiene un buen test, cómo dejar que los tests guíen el código, cómo dejar que el ordenador invente casos de prueba por ti, y cómo evitar que los tests fallen al azar.
Al terminar este capítulo podrás
- Planificar un test antes de escribirlo: el contrato, los casos y lo que dejas fuera
- Estructurar un test como Arrange, Act, Assert, y ponerle un nombre que sea una frase
- Distinguir un test de comportamiento de un test de implementación, y reescribir el segundo como el primero
- Trabajar en ciclos red-green-refactor, con un fallo real en cada paso rojo
- Escribir tests basados en propiedades con Hypothesis, y leer un ejemplo fallido reducido
- Encontrar y arreglar las cuatro causas habituales de los tests flaky
- Convertir cada reporte de bug en un test de regresión
Requisitos previos: tests asíncronos, hooks y plugins.
Antes de escribir el test
La mayoría de los tests malos no están mal escritos. Están mal planificados: se escribieron antes de que nadie decidiera qué promete el código. Así que, antes de cualquier línea de test, cuatro preguntas.
A lo largo de este capítulo construiremos una función pequeña dirigida por tests: slugify, que convierte un título de post, como "Café au lait", en el trozo de URL "cafe-au-lait".
1. ¿Cuál es el contrato? Dilo en una o dos frases, tal como lo ve quien llama: dado un título (un `str`), devuelve un slug: letras y dígitos ASCII en minúsculas, palabras unidas por guiones simples, sin guion en ninguno de los extremos. Las letras acentuadas se convierten en su letra simple. No tiene efectos secundarios: no toca ningún archivo, ningún reloj, ninguna red. Fíjate en lo que el contrato no menciona: expresiones regulares, normalización Unicode, funciones auxiliares. Eso es cómo se hace, y puede cambiar libremente.
2. ¿Qué debe estar listo? Un entorno virtual con las herramientas instaladas, y el código importable desde los tests:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisDespués, dos archivos uno al lado del otro —slugs.py y test_slugs.py— y pytest ejecutado desde esa carpeta, para que from slugs import slugify funcione. Este tema no necesita fixtures, ni archivos temporales, ni variables de entorno. También merece atención: una función pura es lo más fácil del mundo de testear, y es una buena razón para llevar la lógica a funciones puras.
3. ¿Qué casos? Primero el camino feliz, luego los bordes, después la entrada inválida y por último las fronteras:
| Caso | Entrada | Esperado | |---|---|---| | palabras normales | "Hello World" | "hello-world" | | puntuación | "Hello, World!" | "hello-world" | | letras acentuadas | "Café au lait" | "cafe-au-lait" | | espacios sobrantes en los extremos | " Hello World " | "hello-world" | | los dígitos se conservan | "Top 10 Tips" | "top-10-tips" | | nada aprovechable | "!!!" | ? |
Esa interrogación es la celda más útil de la tabla. Al escribir el plan ha salido a la luz una decisión que nadie ha tomado: ¿qué debería pasar cuando un título no contiene ninguna letra? La dejaremos abierta a propósito, y más adelante en este capítulo veremos lo que cuesta una pregunta abierta.
4. ¿Qué no vas a probar? El módulo re de Python, unicodedata y str.lower() ya tienen sus propios tests; volver a probarlos solo te hace más lento. Las funciones auxiliares privadas (cualquier cosa que empiece por _) se alcanzan a través de slugify, nunca directamente. Y no vas a intentar enumerar a mano cada carácter Unicode: eso es trabajo de un test basado en propiedades, más adelante en este capítulo.
La tabla se convierte en los tests. Todo lo que sigue implementa ese plan.
Arrange, Act, Assert
Todo buen test tiene las mismas tres partes, en el mismo orden:
- Arrange — construye el mundo que necesita el test
- Act — haz la única cosa que se está probando
- Assert — comprueba qué ha salido
Aquí tienes el carrito probado de nuevo, esta vez a través de lo que promete, no de cómo guarda las cosas:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0Contra la versión basada en lista, y luego contra la basada en diccionario:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sMismos tests, dos implementaciones, todo en verde las dos veces. Esa es la propiedad que quieres: un refactor que mantiene el comportamiento debe mantener los tests en verde. Los comentarios del test del medio solo están para mostrar la forma; la mayoría de la gente los quita y separa las tres partes con líneas en blanco, como en el tercer test.
¿Por qué importa que el Act sea un único paso? Porque cuando el test falla, quieres saber qué se rompió. Si el Act son cinco llamadas, un fallo apunta a cinco sospechosos.
Probar el comportamiento, no la implementación
La regla práctica: un test solo puede usar lo que puede usar quien llama. Para el carrito eso es Cart(), .add() y .total(). No _items, no _lines. El guion bajo inicial es la forma que tiene Python de decir "esto es mío, y puedo cambiarlo".
La forma incorrecta acopla el test a una decisión que nunca formó parte del contrato: que los artículos se guardan en una lista. La forma correcta hace la pregunta que se haría quien usa el carrito: si añado estas cosas, ¿cuál es el total?
Dos señales de que un test está acoplado a la implementación:
- Lee atributos que empiezan por
_, o simula (mock) una función que el código llama internamente - Un refactor que ningún usuario notaría lo pone en rojo
Los mocks merecen una nota aquí. Los mocks del capítulo once son la herramienta adecuada en una frontera —la red, el reloj, un servicio de pagos. Usados dentro de tu propio código ("comprobar que _calculate se llamó con estos argumentos"), se convierten en tests de implementación con otro nombre.
Nombres que se leen como frases, y una sola razón para fallar
Ejecuta los tres tests del carrito con -v:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]Lee solo los nombres y tienes una especificación del carrito. test_add y test_total solo te dijeron qué método se tocó. Un buen nombre dice la situación y el resultado esperado: un carrito vacío suma cero. Es largo; no pasa nada. Nunca lo escribes, pero lo lees cada vez que falla.
La segunda mitad de la regla es una sola razón para fallar. Aquí tienes lo contrario: un único test que comprueba todo, ejecutado contra un carrito con un bug (ignora quantity):
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=no oculta los tracebacks y deja solo el resumen —la parte que lees primero.) test_cart ha fallado —lo cual no dice nada— con 865.0 == 880.0, que ahora tienes que descifrar. Y la tercera aserción nunca se ejecutó, así que no sabes si habría pasado. El mismo bug contra los tres tests enfocados:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sAntes de abrir un solo traceback, el resumen ya se lee como un diagnóstico: el carrito vacío está bien, pero las cantidades van mal. "Una sola razón para fallar" no significa una sola línea assert —dos aserciones que comprueban dos lados del mismo resultado están bien. Significa un solo comportamiento por test.
La pirámide de tests
Los tests vienen en distintos tamaños:
- Tests unitarios comprueban una función o clase sin nada real alrededor. Milisegundos cada uno. Tienes miles.
- Tests de integración comprueban que las piezas encajan: tu código contra una base de datos real, un sistema de archivos real, una app HTTP real mediante un cliente de test. Más lentos; tienes decenas o cientos.
- Tests end-to-end conducen el sistema completo como lo haría un usuario: un navegador, una API desplegada. Lentos y frágiles; tienes unos pocos, cubriendo los caminos que generan dinero.
Contados, eso forma una pirámide: una base ancha de tests unitarios y una cima estrecha de end-to-end. La razón es el coste. Cuando falla un test unitario, apunta a unas pocas líneas. Cuando falla un end-to-end, apunta a todo el sistema. Empuja cada comprobación lo más abajo posible, y reserva la cima para lo que solo la cima puede ver.
Red, green, refactor
El desarrollo dirigido por tests (TDD) funciona en un bucle estrecho:
- Red — escribe un test pequeño para un comportamiento que todavía no existe, y mira cómo falla
- Green — escribe el código mínimo que lo hace pasar
- Refactor — ordena el código, con los tests en verde todo el tiempo
¿Por qué mirar cómo falla? Porque un test que nunca has visto fallar podría no estar probando nada. El paso rojo demuestra que el test puede detectar la ausencia de la funcionalidad.
Tomamos el plan de la tabla, una fila cada vez.
Red. La primera fila, antes de que exista slugs.py:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sEs un rojo perfectamente válido. El test está pidiendo algo que no existe.
Green. El código mínimo que pasa —y de verdad es el mínimo:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sParece trampa. Es deliberado: el código hace solo lo que un test ha exigido hasta ahora. Cualquier cosa más sería código que ningún test comprueba.
Red. La segunda fila de la tabla:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sLa línea ? marca exactamente los dos caracteres que no deberían estar.
Green. Ahora el truco de reemplazar un espacio no basta. Sustituye cada secuencia de "ni letra ni dígito" por un guion, y luego quita los guiones de los extremos:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sLas filas de espacios sobrantes y de dígitos ya pasarían con este código. Añádelas igualmente —forman parte del contrato—, pero ten en cuenta que un test que está en verde la primera vez que se ejecuta no ha demostrado nada sobre el código que acabas de escribir. Solo un test que va de rojo a verde lo demuestra.
Red. Letras acentuadas:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sé no está en a-z, así que se tiró junto con la puntuación.
Green. La normalización Unicode "NFKD" separa é en e más una marca de acento independiente; codificar a ASCII con "ignore" elimina la marca y conserva la e:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactor. Funciona, pero el cuerpo es una línea densa. Dale un nombre a cada paso, compila la expresión regular una sola vez y añade anotaciones de tipo, sin cambiar ningún comportamiento:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================Este es el paso que hace que el TDD valga la pena. Podías reestructurar libremente porque los tests comprueban el comportamiento; si hubieran tocado _to_ascii o la expresión regular, el refactor los habría roto.
Property-based testing con Hypothesis
Cada test hasta ahora tiene un ejemplo inventado: "Hello World", "Café au lait". Los ejemplos son tan buenos como tu imaginación, y tu imaginación comparte sus puntos ciegos con el código que acabas de escribir.
Una propiedad es una afirmación que se cumple para cada entrada. Hypothesis genera las entradas —cien por test de forma predeterminada— e intenta con empeño encontrar una que rompa la afirmación. @given indica de dónde vienen las entradas; strategies (que siempre se importa como st) describe su forma:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sUn solo punto, pero por ahí pasaron cien listas de precios y cien descuentos. Otras estrategias que te encontrarás: st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...).
¿Qué propiedades tiene slugify? No puedes decir cuál es el slug de una cadena aleatoria, pero sí puedes decir cómo debe verse, y que aplicar slugify a un slug no cambia nada:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text() produce emojis, caracteres chinos, caracteres de control, cadenas vacías —entradas que nunca habrías escrito en la tabla del plan.
Un round trip encuentra un bug real
La propiedad más productiva es el round trip (ida y vuelta): si puedes codificar algo y luego decodificarlo, decodificar la codificación tiene que devolver el original. Aquí tienes un codificador de longitud de ejecución —"aaab" se convierte en "3a1b"— con dos tests de ejemplo que pasan:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sEl texto "0" se codifica como "10" —"un cero"— y el decodificador lee 10 como un recuento sin ningún carácter detrás. Cualquier texto con un dígito se corrompe. Los dos tests de ejemplo solo usaban letras, así que nunca podían verlo.
Shrinking
Hypothesis no tropezó primero con "0". Encontró alguna cadena larga y fea, y luego la redujo: probó entradas más pequeñas y simples, conservando cada una que seguía fallando, hasta que ninguna más simple fallaba. Puedes ver cómo ocurre registrando cada entrada que falla:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seed fija las elecciones aleatorias para que esta ejecución sea repetible; database=None evita que Hypothesis repita un fallo recordado.) El primer fallo son trece caracteres de ruido con un 9 escondido dentro. Te habrías quedado mirándolo durante minutos. El ejemplo reducido cuenta toda la historia con un solo carácter: un dígito lo rompe.
Hypothesis también guarda los ejemplos que fallan en una carpeta .hypothesis/, así que la siguiente ejecución prueba "0" primero. Añade esa carpeta a .gitignore.
La solución pone un separador entre el recuento y el carácter, de modo que un dígito nunca pueda confundirse con parte de un recuento. Y el ejemplo que encontró Hypothesis se fija con @example, así que se ejecuta siempre, en cada máquina:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)Los dos tests de ejemplo cambian al nuevo formato ("3:a1:b"), y la propiedad gana una línea:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sLas propiedades no reemplazan a los ejemplos. Los dos tests de ejemplo documentan el formato de una forma que el lector entiende de un vistazo; la propiedad vigila las esquinas en las que nadie pensó. Usa ambos.
Tests flaky
Un test flaky pasa y falla con el mismo código. Es peor que no tener test: la gente aprende a pulsar "volver a ejecutar" y deja de creer en el rojo. Casi todos los tests flaky vienen de una de cuatro causas, y cada una tiene una solución que elimina la causa en lugar de esconderla.
Estado compartido y orden de los tests
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sFalla en la suite, pasa cuando se ejecuta sola. El set a nivel de módulo sobrevive de un test al siguiente, así que el segundo test ve lo que dejó el primero. Cambia el orden, o ejecuta los tests en paralelo con pytest-xdist, y el resultado cambia. La solución equivocada es reordenar los tests. La solución correcta elimina el estado compartido: convierte el registro en un objeto, y dale a cada test uno nuevo mediante una fixture:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sCuando no puedes cambiar el código, una fixture autouse que reinicia el estado antes y después de cada test es la alternativa. Cada test debería pasar solo y en cualquier orden.
Tiempo
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonEl mismo código, ejecutado en el mismo momento, en dos máquinas configuradas con zonas horarias distintas:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sLa solución ya está en la firma de la función: se puede pasar now. Un test que controla el reloj prueba ambos lados de la frontera, y da la misma respuesta a cualquier hora:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sPasar la hora como argumento es más simple que cualquier mock. Cuando no puedes cambiar la firma, monkeypatch del capítulo diez reemplaza el reloj en su lugar.
Aleatoriedad
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"Seis ejecuciones, sin cambiar nada:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sDos soluciones, para dos tipos de pregunta. Afirma lo que es cierto para todos los resultados —el ganador es uno de los participantes. O controla la aleatoriedad pasando un generador con semilla:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sEl mundo exterior
La cuarta causa es cualquier cosa que no controlas: una llamada de red real, un servidor real, un sleep(0.1) que "debería bastar". Las soluciones son las de capítulos anteriores —simula (mock) en la frontera, usa tmp_path en lugar de una carpeta compartida, espera a una condición en lugar de a un tiempo fijo. Una sola forma cubre las cuatro causas: todo aquello de lo que depende el test, el test debe crearlo o controlarlo.
Un test de regresión para cada bug
Volvamos a la interrogación de la tabla del plan. Nadie la respondió, y llega el reporte de bug: un post titulado `"!!!"` se guardó en `/posts/`, sobrescribiendo la página de índice.
Antes de tocar slugify, escribe un test que reproduzca el reporte y mira cómo falla. Eso demuestra que el test detecta este bug:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sAhora arréglalo: el final de slugify queda así:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sEl test de regresión y los tres tests de ejemplo pasan. Pero ejecuta la suite completa, y los tests de propiedades protestan:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesis probó la cadena vacía, y slugify("") ahora lanza una excepción. Eso no es un bug nuevo: es el contrato cambiando a propósito, y las propiedades dándose cuenta. Bien. Actualízalas para que expresen el nuevo contrato: títulos que contengan al menos una letra o un dígito.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothing cambia a @given(titles) de la misma manera.
...... [100%]
6 passed in 0.01s¿Por qué un test para cada bug? Porque un bug que ocurrió una vez ha demostrado que es fácil de cometer, y la siguiente persona que toque ese código probablemente lo cometa de nuevo. El comentario que explica qué salió mal forma parte del test: dentro de un año, es el único registro de por qué existe este caso tan raro.
Un ejemplo completo
Un verificador de fortaleza de contraseñas, construido con todo lo de este capítulo. Primero el plan:
| Caso | Entrada | Esperado | |---|---|---| | vacía | "" | weak | | todos los tipos, demasiado corta | "Ab1!xyz" (7) | weak | | lo bastante larga, un tipo | "abcdefgh" | weak | | lo bastante larga, dos tipos | "abcdefg1" | medium | | 12 de largo, tres tipos | "abcdefghij1!" | strong | | 12 de largo, dos tipos | "abcdefghijk1" | medium |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================Hay tres decisiones que merece la pena notar.
Las filas de la tabla caen en las fronteras —7 y 8 caracteres, 11 y 12, dos tipos y tres. Los bugs viven en las fronteras: un < que debería ser <= es invisible en el medio de un rango.
La propiedad dice algo que ningún ejemplo puede decir: añadir caracteres nunca hace una contraseña más débil. Nadie escribiría ese test a mano para cada contraseña, y Hypothesis lo comprueba con cientos, incluidas letras Unicode que no son ni mayúsculas ni minúsculas.
Y ningún test toca _kinds. Se alcanza a través de strength; mañana puede desaparecer.
Cuando algo falla
AttributeError: 'Cart' object has no attribute '_items' después de un refactor El test estaba leyendo el interior del objeto. Reescríbelo para comprobar a través de los métodos públicos —lo que un llamador puede ver— y sobrevivirá también al siguiente refactor.
Un test pasa solo pero falla en la ejecución completa (o al revés) Estado compartido: una lista, un diccionario o una caché a nivel de módulo; un archivo en una ubicación fija; una variable de entorno establecida y nunca desactivada. Haz que cada test cree lo que necesita (fixtures, tmp_path, monkeypatch) en lugar de reordenar los tests.
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. Un .filter() (o assume()) descarta casi todo lo que genera Hypothesis —por ejemplo st.integers().filter(lambda n: n % 1000 == 7). Construye directamente los valores que quieres: st.integers().map(lambda n: n * 1000 + 7).
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one El test dio una respuesta distinta para la misma entrada. Algo fuera de los argumentos generados cambió entre llamadas —un contador, una lista a nivel de módulo, el reloj. Hypothesis repite cada fallo, así que un test con estado oculto no puede reducirse.
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. Cada ejemplo generado debe terminar en 200 ms por defecto, porque cien ejemplos lentos hacen una suite lenta. Haz el test más rápido o, si la lentitud es real, sube el límite con @settings(deadline=...).
Un test TDD nuevo pasa la primera vez que lo ejecutas No está demostrando nada sobre el código que estás a punto de escribir. O el comportamiento ya existe (bien: conserva el test como documentación), o el test no comprueba lo que crees. Rompe el código a propósito un momento y confirma que el test se pone en rojo.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Ejecutado como archivo completo, el segundo test falla. ¿Cuál es la última línea cuando solo se ejecuta el segundo test?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cart pasa de una lista a un diccionario por dentro, y su comportamiento sigue igual. ¿Qué test se pone en rojo?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
Este test pasa de lunes a viernes y falla el sábado y el domingo. ¿Cuál es la mejor solución?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- APasar una fecha fija — `is_weekend(date(2026, 10, 7))` — y añadir un test aparte para un sábado
- BEscribir `== False` en lugar de `is False`
- CVolver a ejecutar el test automáticamente cuando falle
- DSaltar el test los sábados y domingos
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
Construye dos funciones, dirigidas por tests, en durations.py:
parse_duration(text)convierte"1h30m"en5400(segundos). Las unidades sonh,m,s, en ese orden, cada una opcional;"45s","2h"y"1h0m5s"son todas válidas. Cualquier otra cosa —"","h","1x","30m1h","1.5h"— lanzaValueErrorconnot a durationen el mensaje.format_duration(seconds)hace lo contrario:5400se convierte en"1h30m",3605en"1h5s",0en"0s". Un número negativo lanzaValueError.
Trabaja en este orden:
- Escribe primero la tabla del plan: caso, entrada, esperado —incluyendo las entradas inválidas y las fronteras (
59y60segundos, por ejemplo). - Avanza fila a fila en ciclos red-green. Ejecuta
pytestdespués de cada cambio y mira el rojo antes de ponerlo en verde. - Añade la propiedad de round trip con Hypothesis: para cualquier número entero de segundos desde
0en adelante,parse_duration(format_duration(n)) == n. - Un usuario informa de que
"90m"fue rechazado, aunque la gente lo escribe todo el tiempo. Añade un test de regresión con un comentario que diga qué salió mal.
Cuando termines, refactoriza una vez y confirma que la suite completa sigue en verde.
Solución
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sPor qué está construido así:
- El plan se convirtió en las tablas de
parametrize. Cada fila de la tabla es una fila deparametrize, así que añadir un caso más tarde es una línea, y los nombres de test en la salida de-vmuestran qué fila falló. - Las entradas válidas e inválidas son tests separados. Son comportamientos distintos con motivos distintos para fallar. La lista de inválidas contiene un ejemplo de cada tipo de error: vacía, unidad sin número, unidad desconocida, orden incorrecto, decimal.
match="not a duration"comprueba el mensaje además del tipo, de modo que unValueErrorlanzado por accidente en otra parte —int(""), por ejemplo— no puede hacer pasar el test.if not textestá ahí porque el patrón, con todas las partes opcionales, coincide con la cadena vacía. Ese es exactamente el tipo de caso que la tabla del plan te obliga a pensar antes de que la expresión regular lo resuelva por ti.59y60son la frontera donde los segundos se convierten en minutos; un test en mitad del rango nunca detectaría un error de uno ahí.- La propiedad de round trip es el test más fuerte del archivo. No conoce ningún valor esperado, y aun así compara diez millones de duraciones posibles entre sí —cualquier desacuerdo entre las dos funciones aparece, reducido al número más pequeño que lo rompe.
- El test de regresión ya pasa con esta implementación; existe para evitar que una futura "limpieza" que limite los minutos a
0-59vuelva a traer el bug. Su comentario explica por qué. - Nada prueba
PATTERNniUNITSdirectamente. Son implementación, y puedes sustituir la expresión regular por un parser escrito a mano mañana mismo.
Dónde seguir
Este es el final del curso. Ya sabes escribir tests, leer sus fallos, estructurarlos, aislarlos, medirlos, extender el propio pytest y —después de este capítulo— decidir qué merece la pena probar en primer lugar. Algunas direcciones a partir de aquí:
- Hazlo un hábito con código real. Toma un proyecto que ya tengas y añade tests a la parte que más miedo te da cambiar. Escribe primero la tabla del plan. El miedo baja a medida que sube el número de tests.
- Ve más lejos con Hypothesis. Su documentación cubre los tests con estado (
RuleBasedStateMachine), que generan secuencias completas de operaciones contra un objeto, y encuentran bugs que ninguna entrada individual podría encontrar. - Mutation testing. Herramientas como
mutmuthacen pequeños cambios en tu código —<por<=,+por-— y comprueban que algún test falle. Una mutación que sobrevive es una línea que tus tests no comprueban de verdad, por alto que sea el número de cobertura. - Amplía la configuración de CI del capítulo catorce. Ejecuta la suite en cada versión de Python que soportes, y haz que un test fallido bloquee el merge: una suite que nadie está obligado a mantener en verde deja de estar en verde poco a poco.
- Lee buenas suites de tests. Los tests de
pytest,requests,attrsy el propiohypothesisson de código abierto. Leer cómo nombran, estructuran y aíslan los tests personas con experiencia enseña más que cualquier lista de reglas.
Lo que escribas a continuación, empieza por la pregunta con la que se abrió este capítulo: si alguien mejora este código mañana sin cambiar lo que hace, ¿seguirán mis tests en verde —y si alguien lo rompe, se pondrán en rojo?
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