Módulo 05 · Testing¶
Prerrequisitos: módulos 01-04
Tiempo estimado: 240 min
Si ya dominas esto: salta al módulo 06
Este módulo tiene una particularidad: sus tests son el material didáctico. Llevas cuatro módulos resolviendo ejercicios contra tests que otra persona escribió. Aquí toca mirarlos por dentro, entender por qué están escritos así, y empezar a escribir los tuyos.
Ábrelos mientras lees:
ruta/05-testing/tests/base/test_dias_habiles.py
ruta/05-testing/tests/base/test_validador.py
ruta/05-testing/tests/reto/test_libro.py
Qué vas a poder hacer al terminar¶
- Escribir tests con pytest
- Usar fixtures,
parametrize, marcas yconftest.py - Comparar valores aproximados sin falsos negativos
- Escribir dobles de prueba y saber dónde ponerlos
- Leer un informe de cobertura sin sacar conclusiones equivocadas
- Escribir el test antes que el código
1. Para qué se prueba de verdad¶
El malentendido más extendido es que se prueba para encontrar bugs. Se prueba para otra cosa, mucho más valiosa:
No se prueba para encontrar bugs. Se prueba para poder cambiar el código sin miedo.
Un test no es una red que atrapa errores hoy: es la infraestructura que hace seguro el cambio de mañana. Refactorizar, añadir una funcionalidad, actualizar una dependencia — todo eso se vuelve posible sin la parálisis del "¿qué voy a romper?".
Una suite de tests es lo que convierte el código de un edificio que no se puede tocar sin que se derrumbe en uno que se puede reformar. Es la misma inversión que los tipos del módulo 04: pagar rigor por adelantado para comprar libertad después.
Por qué no puedes evitarlo¶
Hay una tentación razonable: "el código es sencillo, lo he leído y está bien". El problema es que leer tu propio código no es una prueba independiente. Sabes lo que quisiste escribir, y eso te hace ver lo que quisiste, no lo que hay. Es el mismo motivo por el que no se corrigen los propios exámenes.
Y hay una segunda razón, menos obvia y más importante: el código que hoy es sencillo no lo va a ser dentro de un año. Los tests que escribes ahora, sobre lo fácil, son los que van a sostener lo difícil cuando llegue.
2. pytest: assert a secas¶
def test_business_days_between_una_semana_completa():
assert business_days_between(date(2026, 3, 2), date(2026, 3, 9)) == 5
Sin assertEqual, sin heredar de nada, sin clases. Una función que empieza por
test_ y un assert. Cuando falla, pytest reescribe la expresión y te enseña
los dos valores:
El nombre del test es documentación ejecutable.
test_business_days_between_una_semana_completa dice qué invariante se rompió
sin que tengas que leer el cuerpo. Compáralo con test_1 o test_funciona.
Y cada test verifica una cosa. El antipatrón es el test gigante que
comprueba diez y cuyo fallo no te dice cuál de las diez — tan inútil como el
except Exception: pass del módulo 00.
Cómo se ejecutan¶
uv run pytest # todo
uv run pytest ruta/05-testing # una carpeta
uv run pytest -k "validador" # los que lleven eso en el nombre
uv run pytest -x # para en el primer fallo
uv run pytest --lf # solo los que fallaron la última vez
uv run pytest -q # salida breve
uv run pytest -v # el nombre de cada test
uv run pytest --durations=5 # los cinco más lentos
--lf (last failed) es el que más tiempo ahorra en el día a día: arreglas,
lo vuelves a lanzar y solo corre lo que estaba roto.
3. parametrize: la tabla de casos como datos¶
Cuando el mismo test se repite con distintos valores, no lo copies:
@pytest.mark.parametrize(
("cents", "esperado"),
[
(0, "0.00"),
(1_500, "15.00"),
(99, "0.99"),
(-500, "-5.00"),
],
)
def test_format_money(cents, esperado):
assert format_money(cents) == esperado
Eso son cuatro tests, no uno: pytest los ejecuta por separado y te dice exactamente cuál falló. Separas la lógica de la prueba de los datos que la ejercen, que es el mismo principio de diseño que llevas aplicando desde el módulo 02.
Cuando un caso necesita nombre propio, se le pone:
@pytest.mark.parametrize(
("entrada", "esperado"),
[
pytest.param("", 0, id="cadena vacía"),
pytest.param(" ", 0, id="solo espacios"),
],
)
def test_contar(entrada, esperado): ...
4. pytest.raises: los errores también son comportamiento¶
Que tu función falle bien es parte de su contrato, y se prueba igual:
def test_no_se_puede_retirar_de_mas():
with pytest.raises(InsufficientFunds):
Account("Ana", 1_000).withdraw(5_000)
Y puedes ir más allá, comprobando que el mensaje sirve para algo:
def test_el_error_dice_cuanto_habia():
with pytest.raises(InsufficientFunds) as error:
Account("Ana", 1_000).withdraw(5_000)
assert "1000" in str(error.value)
# o, más corto, con una expresión regular:
def test_el_error_menciona_el_saldo():
with pytest.raises(InsufficientFunds, match="1000"):
Account("Ana", 1_000).withdraw(5_000)
Ese test parece pedante hasta la primera vez que lees un log a las tres de la
mañana y el error solo dice ValueError.
Un aviso que este repositorio aprendió por las malas: NotImplementedError
hereda de RuntimeError. Un pytest.raises(RuntimeError) pasa contra un stub
sin resolver, y el test parece verde sin probar nada. Por eso varios tests de la
ruta añaden match=.
5. Fixtures: el ciclo de vida de lo que el test necesita¶
Una fixture prepara algo, se lo entrega al test y luego lo recoge:
@pytest.fixture
def libro():
ledger = Ledger()
ledger.add("TR-001", 1_500)
ledger.add("TR-002", 2_500)
return ledger
def test_el_saldo_suma_las_entradas(libro):
assert libro.balance == 4_000
El test pide libro como parámetro y pytest se lo construye. Cada test recibe
uno nuevo: no hay estado compartido que haga que un test pase o falle según
el orden en que se ejecuten.
Cuando además hay que limpiar, se usa yield:
@pytest.fixture
def base_de_datos():
conexion = conectar()
yield conexion # aquí corre el test
conexion.cerrar() # y esto se ejecuta siempre, aunque el test falle
Es exactamente el patrón de los context managers: preparar, ceder el control, recoger.
Alcance: cuántas veces se construye¶
@pytest.fixture(scope="session") # una vez para toda la ejecución
def contenedor_postgres(): ...
@pytest.fixture(scope="module") # una vez por archivo de tests
def datos_grandes(): ...
@pytest.fixture # (por defecto) una vez por test
def libro(): ...
El criterio: por defecto siempre, y subes el alcance solo cuando construirlo es caro y de verdad es de solo lectura. Una fixture de sesión que los tests modifican reintroduce el acoplamiento por orden que las fixtures venían a eliminar.
conftest.py: fixtures compartidas¶
Una fixture definida en conftest.py está disponible para todos los tests de
esa carpeta y sus subcarpetas, sin importar nada.
La fixture solution que usan todos los ejercicios de este repositorio está
escrita así — ábrela en el conftest.py de la raíz, ya la entiendes entera.
Fixtures que ya vienen¶
def test_escribe_un_archivo(tmp_path): # carpeta temporal, se borra sola
destino = tmp_path / "salida.txt"
destino.write_text("hola")
assert destino.read_text() == "hola"
def test_lee_el_entorno(monkeypatch): # cambia entorno/atributos y lo deshace
monkeypatch.setenv("DEBUG", "1")
assert config.debug is True
def test_imprime_algo(capsys): # captura lo que va a stdout
saludar("Ana")
assert "Ana" in capsys.readouterr().out
tmp_path y monkeypatch resuelven el 90% de los casos donde uno se plantea
usar un mock.
6. Marcas: clasificar y saltar¶
@pytest.mark.slow
def test_procesa_un_millon_de_filas(): ...
@pytest.mark.skip(reason="pendiente de la API v2")
def test_futuro(): ...
@pytest.mark.skipif(sys.platform == "win32", reason="rutas POSIX")
def test_permisos(): ...
@pytest.mark.xfail(reason="bug conocido #142", strict=True)
def test_caso_roto(): ...
xfail es más honesto que skip para un bug conocido: el test se ejecuta,
y con strict=True te avisa si un día empieza a pasar — que es justo cuando
quieres enterarte.
Las marcas propias se declaran en pyproject.toml y sirven para separar
ejecuciones:
7. Comparar cosas que no son exactas¶
Los float no se comparan con == — el módulo 01 explicó por qué:
assert 0.1 + 0.2 == pytest.approx(0.3)
assert resultado == pytest.approx(97.88, rel=1e-3)
assert [0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0]) # también sobre listas
approx acepta tolerancia relativa (rel) y absoluta (abs). La relativa es
la correcta casi siempre; la absoluta hace falta cuando el valor esperado es
cero, porque un porcentaje de cero es cero. Vas a implementarlo tú en el
ejercicio comparar, que es la mejor forma de no volver a usarlo mal.
8. Dobles de prueba: en la frontera, no en el dominio¶
Un doble sustituye una dependencia real (la base de datos, un LLM) por algo controlado. Hay varios tipos y conviene distinguirlos:
| Tipo | Qué hace |
|---|---|
| Stub | devuelve respuestas fijas |
| Fake | implementación simplificada pero funcional (un repositorio en memoria) |
| Spy | registra cómo lo llamaron, para comprobarlo después |
| Mock | un spy con expectativas declaradas de antemano |
La disciplina cabe en una frase: se dobla en la frontera, no en el dominio.
El dominio puro no necesita dobles — no tiene nada que doblar, y esa es su virtud. Lo que se sustituye son los adaptadores: el cliente HTTP, el repositorio, el reloj. Si te ves parcheando funciones internas de tu propio módulo para poder probarlo, el problema no es el test.
Y la forma más limpia de doblar algo casi nunca es una librería de mocking: es inyectarlo. Lo llevas haciendo desde el módulo 07 con el reloj de los reintentos y desde el 11 con el pipeline. Cuando una dependencia entra por parámetro, el doble es una función normal de tres líneas.
def test_reintenta_dos_veces():
esperas = []
solution.retry(operacion_que_falla(2), sleeper=esperas.append)
assert esperas == [1.0, 2.0]
Sin unittest.mock, sin parches, sin magia. Eso es tu ejercicio doble.
9. La pirámide: qué probar y a qué nivel¶
╱╲ pocos end-to-end: lentos, frágiles, insustituibles
╱ ╲ para saber si el sistema entero vive
╱────╲ algunos integración: ¿hablan bien dos piezas reales?
╱ ╲
╱────────╲ muchos unitarios: dominio puro, milisegundos,
╱__________╲ ninguna dependencia externa
La base es ancha porque los tests unitarios del dominio son baratos, rápidos y precisos: cuando falla uno, sabes exactamente qué se rompió. Los de arriba son caros y difusos, pero son los únicos que responden "¿esto funciona de verdad?".
De aquí sale un consejo de diseño que vale más que la propia pirámide: si algo es difícil de probar, casi siempre está mal diseñado. Una función que lee un archivo, llama a una API, calcula y escribe en base de datos es imposible de probar bien. Separa el cálculo puro de los efectos y el cálculo se vuelve trivial de probar.
10. Cobertura: lo que mide y lo que no¶
La cobertura dice qué líneas se ejecutaron durante la suite. Y nada más. No dice si comprobaste algo útil sobre ellas.
def test_inutil():
reconcile(movimientos, registros) # 100% de cobertura de reconcile
# cero afirmaciones
Por eso el número es un mapa de dónde no has mirado, no una nota. Un 60%
con tests buenos vale más que un 95% con tests que no afirman nada. Lo útil de
verdad es --cov-report=term-missing: la lista de líneas que ningún test
ejecuta, que suele señalar ramas de error olvidadas.
11. Propiedades en vez de ejemplos¶
El testing por ejemplos prueba los casos que se te ocurren. El testing basado en propiedades declara qué debe ser cierto siempre y deja que una herramienta busque el contraejemplo:
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_ordenar_conserva_la_longitud(numeros):
assert len(sorted(numeros)) == len(numeros)
@given(st.integers(), st.integers())
def test_sumar_dinero_es_conmutativo(a, b):
assert Money(a) + Money(b) == Money(b) + Money(a)
Hypothesis genera cientos de entradas, incluidas las que a un humano no se le ocurren: el cero, el negativo, la lista vacía, el número enorme, el texto con emojis. Y cuando encuentra un fallo hace shrinking: reduce el contraejemplo al mínimo que lo reproduce, así que no te dice "falla con [4823, -991, 0, 17]" sino "falla con [0]".
El cambio de mentalidad es el valor real: pasas de ¿qué ejemplos pruebo? a ¿qué tiene que ser siempre verdad?
12. TDD: rojo, verde, refactor¶
- Rojo. Escribe el test. Ejecútalo y compruébalo fallando. Un test que nunca has visto fallar puede que no pruebe nada.
- Verde. Escribe el código mínimo que lo hace pasar.
- Refactor. Ahora arréglalo bien, con la red puesta.
Es exactamente el ciclo que llevas haciendo desde el módulo 01: los ejercicios te llegan en rojo a propósito.
Y cuando encuentres un bug en producción, el orden correcto es el mismo: primero el test que lo reproduce, luego el arreglo. Ese test es lo que garantiza que no vuelva.
Caso real¶
Una función de conciliación tenía cobertura del 94% y una suite verde. Un cambio de una línea la rompió en producción sin que ningún test se enterara.
Al mirar la suite, tres patologías:
# 1. El test que no prueba nada
def test_reconcile():
resultado = reconcile(movimientos, registros)
assert resultado is not None # ¿y qué? Casi nada es None
# 2. El test que prueba el mock
def test_guarda_en_base_de_datos():
db = Mock()
guardar(db, transferencia)
db.save.assert_called_once() # prueba que llamaste, no que funcione
# 3. El test gigante
def test_flujo_completo():
... # 80 líneas, 14 asserts
# cuando falla, dice "falló test_flujo_completo". Gracias.
Los tres suben la cobertura y ninguno da confianza. El arreglo no fue escribir más tests, fue escribir menos y mejores: la lógica de conciliación se extrajo a una función pura y se probó con veinte casos parametrizados, incluidos los límites que nadie había considerado — la lista vacía, las referencias duplicadas, el movimiento sin contraparte.
Ejercicios¶
Antes de resolverlos, lee los tests. Cada uno demuestra una técnica:
ejercicios/base/dias_habiles.py— sus tests usanparametrizea fondo: mira cómo una tabla de casos sustituye a diez funciones copiadas.ejercicios/base/validador.py— sus tests usanpytest.raisesy comprueban la jerarquía de excepciones, no solo que "falle".ejercicios/base/comparar.py— implementar lo que hacepytest.approx, para no volver a usarlo mal.ejercicios/reto/libro.py— sus tests usan una fixture. Fíjate en que cada test recibe un libro nuevo.ejercicios/reto/doble.py— escribir un spy y un reloj falso a mano, sin librería de mocking.
Resumen¶
- No se prueba para encontrar bugs: se prueba para poder cambiar sin miedo.
- Leer tu propio código no es una prueba independiente.
- pytest usa
asserta secas y reescribe la expresión cuando falla. - El nombre del test es documentación ejecutable. Un test, una cosa.
--lfejecuta solo lo que falló: es el que más tiempo ahorra.parametrizeconvierte la tabla de casos en datos: N tests con una función.- Los errores son parte del contrato:
pytest.raises, y conmatch=cuando el mensaje importa. Ojo:NotImplementedErrores unRuntimeError. - Las fixtures gestionan el ciclo de vida; con
yieldlimpian siempre. Alcance por defecto salvo que construir sea caro y de solo lectura. conftest.pycomparte fixtures sin imports.tmp_pathymonkeypatchevitan la mayoría de los mocks.xfail(strict=True)es más honesto queskippara un bug conocido.- Los
floatse comparan conapprox; la tolerancia absoluta hace falta cuando el esperado es cero. - Se dobla en la frontera, nunca en el dominio, y casi siempre inyectando en vez de parcheando.
- La cobertura dice dónde no has mirado; no dice que lo que miraste esté bien.
- Property-based testing cambia "¿qué ejemplos pruebo?" por "¿qué debe ser siempre verdad?".
- Ante un bug: primero el test que lo reproduce, después el arreglo.
Preguntas de repaso¶
- ¿Por qué un test que nunca has visto fallar es sospechoso?
- Tienes ocho casos que ejercen la misma lógica con distintos valores. ¿Copias el test ocho veces?
- ¿Qué diferencia hay entre una fixture con
returny una conyield? - ¿Cuándo subirías el alcance de una fixture a
session, y qué riesgo asumes? - Escribes
pytest.raises(RuntimeError)contra una función sin implementar y el test pasa. ¿Qué ocurrió? - ¿Por qué
assert resultado == 0.3puede fallar yapproxno? - Un compañero dice que su módulo tiene 100% de cobertura. ¿Qué le preguntas?
- Para probar una función que lee un archivo, llama a una API y calcula un total, ¿qué cambiarías antes de escribir un solo test?
- ¿Por qué mockear una función interna de tu propio módulo es una señal de alarma?
- ¿Qué ventaja tiene
xfail(strict=True)sobreskippara un bug conocido? - Encuentras un bug en producción. ¿Qué escribes primero?
Recursos¶
- Documentación de pytest —
doc-oficial·en·intermedio. Empieza por Get Started y por la página de fixtures. - Fixtures de pytest
—
doc-oficial·en·intermedio. Alcances,yield, y las que ya vienen. unittest.mock—doc-oficial·es·intermedio. Para cuando toque doblar una frontera y la inyección no baste.- Hypothesis —
doc-oficial·en·avanzado. Property-based testing, con su capítulo sobre cómo elegir propiedades. - coverage.py —
doc-oficial·en·intermedio. Qué mide exactamente y cómo configurar qué se excluye.
Siguiente¶
Módulo 06 · Async y concurrencia.