Un buen suite de tests no es el que tiene más tests. Es el que te dice rápidamente si los usuarios aún pueden hacer las cosas por las que te pagan. Testea fronteras arriesgadas, mantén la mayoría de comprobaciones baratas y niégate a preservar trivialidades de implementación.
🎙️ Publicado y grabado: ·
Empieza donde la información cambia de forma o de dueño: una petición HTTP se convierte en un comando, el dinero se convierte en céntimos enteros almacenados, tu código llama a un servicio de pago o una transacción de base de datos hace commit. Esas costuras contienen más bugs reales que un helper privado que une dos strings. Para cada frontera, cubre un caso normal, un rechazo y un borde incómodo.
# pytest: observable behavior at the HTTP boundary
def test_rejects_zero_quantity(client):
response = client.post("/orders", json={"sku": "A7", "quantity": 0})
assert response.status_code == 422
assert response.json()["error"] == "quantity must be at least 1"No empieces testeando getters, cableado del framework o comportamiento del lenguaje. Si borrar un test no haría que una regresión plausible diera más miedo, ese test probablemente es inventario, no protección.
La idea útil detrás de la pirámide es económica: las comprobaciones baratas y deterministas deben superar en número a las comprobaciones lentas y amplias. No es una cuota. Una librería de parseo puede merecer miles de tests unitarios y casi ningún test de navegador. Un flujo de checkout merece un puñado de caminos reales de navegador porque el navegador, la API, la base de datos y el handoff de pago son el producto.
# A practical portfolio, not a mandated ratio
unit many milliseconds pure rules and transformations
integration enough seconds database, queue, filesystem, adapters
end-to-end few minutes revenue and account-critical journeysNo mockees un sistema en el que necesitas confianza. Si la sintaxis de PostgreSQL, el comportamiento transaccional o las constraints importan, ejecuta PostgreSQL en un test de integración. Mantén los tests end-to-end pocos porque los fallos son costosos de localizar, no porque un triángulo en una diapositiva de conferencia lo dijera.
Arrange crea el mundo significativo más pequeño. Act ejecuta un comportamiento. Assert comprueba el resultado que importa. El patrón es valioso porque un lector puede identificar el estímulo y la consecuencia en segundos. No es una exigencia de comentarios ni de exactamente una assertion. Varias assertions sobre una factura devuelta siguen siendo una razón para fallar.
def test_applies_member_discount():
# Arrange
cart = Cart(items=[Item(price_cents=2500)], member=True)
# Act
total = cart.total()
# Assert
assert total.subtotal_cents == 2500
assert total.discount_cents == 250
assert total.due_cents == 2250AssertionError: assert 2249 == 2250
+ where 2249 = Total(...).due_centsUno: re-ejecuta solo este test para probar que es estable. Dos: inspecciona el primer valor que difiere, aquí dos mil doscientos cuarenta y nueve frente a dos mil doscientos cincuenta. Tres: rastrea dónde ocurre el redondeo. Cuatro: corrige la regla de redondeo de producción o la regla de negocio esperada; nunca sumes uno a la assertion solo para poner el build en verde.
Un test debe sobrevivir a un refactor que preserve el comportamiento. Aserta el recibo, la fila almacenada, el evento emitido o el error devuelto. Evita asertar el orden de métodos privados y cada llamada interna. Para colaboradores, un stub devuelve datos preparados, un fake implementa una versión pequeña funcional, y un spy registra una interacción. Usa una expectación de mock solo cuando la interacción misma es el contrato, como enviar exactamente una petición de reembolso.
class FakeGateway:
def __init__(self): self.charges = []
def charge(self, cents):
self.charges.append(cents)
return {"id": "ch_test_1", "status": "paid"}
def test_checkout_charges_the_final_total():
gateway = FakeGateway()
receipt = checkout(Cart.totaling(4200), gateway)
assert receipt.payment_id == "ch_test_1"
assert gateway.charges == [4200]Un fake de pasarela de pago es excelente para tests de dominio e insuficiente para probar que tu adaptador real envía los headers requeridos por el proveedor. Dale al adaptador un test de contrato o un test de integración con sandbox. Un double compra velocidad renunciando a realismo; di claramente a qué realismo renunciaste.
Un sleep no es sincronización. Un test que espera quinientos milisegundos espera que el worker termine a tiempo; bajo carga, a veces no lo hará. Haz await de la tarea, consulta un estado durable con un deadline, o expón una señal de completado. Inyecta un reloj en vez de congelar el tiempo global, luego avánzalo explícitamente.
// Jest: await the promise and control the clock
test("expires a reset token after 15 minutes", async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-07-25T10:00:00Z"));
const token = await issueResetToken("user_7");
jest.setSystemTime(new Date("2026-07-25T10:16:00Z"));
await expect(redeem(token)).rejects.toThrow("token expired");
jest.useRealTimers();
});Jest did not exit one second after the test run has completed.
This usually means that there are asynchronous operations that weren't stopped.Uno: re-ejecuta con --detectOpenHandles. Dos: inspecciona el servidor, socket, timer o pool de base de datos reportado. Tres: cierra ese recurso en afterEach o afterAll y haz await de la operación de cierre. Cuatro: restaura los timers reales. No uses force exit; oculta recursos de producción con fugas.
Los tests de repositorio deben ejecutarse contra el mismo motor de base de datos que producción. SQLite no es un PostgreSQL pequeño; las constraints, JSON, el locking y el comportamiento transaccional difieren. Inicia una base de datos aislada, aplica las migraciones reales y resetea el estado por test. En las fronteras de servicio, valida peticiones y respuestas contra un schema compartido para que los desacuerdos entre productor y consumidor fallen antes del despliegue.
# Integration test: prove the database enforces the invariant
def test_email_is_unique(db):
db.users.insert(email="[email protected]")
with pytest.raises(UniqueViolation):
db.users.insert(email="[email protected]")
# Consumer contract: fields actually used by this client
{ "id": "usr_12", "plan": "pro", "active": true }fixture 'db' not found
available fixtures: capfd, caplog, capsys, monkeypatch, tmp_pathUno: ejecuta pytest --fixtures -q y confirma que la fixture está ausente. Dos: asegúrate de que conftest.py está en este directorio de tests o en un directorio padre. Tres: comprueba que el plugin que proporciona la fixture está instalado y habilitado. Cuatro: corrige el nombre o el scope de la fixture. No añadas una fixture db vacía; eso solo convierte un error de setup en un test engañoso.
Un test en rojo puede significar una regresión de producción, una expectativa incorrecta, un setup roto, estado contaminado o un test inestable. Primero ejecuta solo ese test. Luego lee el primer frame útil de la aplicación y el diff completo esperado-vs-real. Reprodúcelo dos veces antes de tocar código. Si pasa solo, investiga dependencia de orden y estado filtrado.
# Narrow first; add detail only when needed
pytest tests/orders/test_discount.py::test_member_discount -vv
pytest tests/orders/test_discount.py::test_member_discount -vv --showlocals
# Then prove or reject order dependence
pytest tests/orders/test_discount.py tests/users/test_profile.py -vvNo actualices snapshots hasta que puedas explicar cada línea cambiada. No debilites igualdad a "contains" porque un fallo es incómodo. El mensaje de fallo es evidencia. Presérvalo, reduce la reproducción, identifica la suposición cambiada y solo entonces decide si el código de producción, los datos de test o la expectativa están mal.
Un test inestable entrena al equipo a ignorar builds en rojo. La cuarentena puede mantener el pipeline principal usable durante un día, pero debe crear un ticket de reparación con dueño y seguir visible. Causas comunes son estado compartido, relojes reales, datos aleatorios sin semilla, llamadas de red, resultados sin orden, puertos fijos y sleeps.
# Expose intermittence and preserve the seed
pytest tests/test_worker.py -x --count=100
# Bad: result order belongs to the scheduler
assert [job.id for job in completed] == [1, 2, 3]
# Good, only if the product contract says order is irrelevant
assert {job.id for job in completed} == {1, 2, 3}Uno: registra la semilla fallida, worker ID, timezone y orden de tests. Dos: repite en bucle la reproducción más pequeña. Tres: elimina una entrada no controlada a la vez. Cuatro: demuestra la corrección con ejecuciones repetidas. Los reintentos automáticos pueden recoger evidencia, pero un reintento exitoso debe seguir marcando el build como inestable.
La cobertura de líneas responde si una línea se ejecutó, no si alguien comprobó el resultado. Un test puede ejecutar cada rama y no asertar nada. Usa la cobertura para descubrir riesgos no tocados, especialmente manejo de errores y autorización. Prefiere cobertura de ramas para código con muchas decisiones. Un umbral modesto que nunca decrece es útil; perseguir el cien por cien normalmente compra tests frágiles para código trivial.
def can_refund(order, actor):
if not actor.is_staff: # authorization branch
return False
if order.age_days > 30: # policy branch
return False
return order.status == "paid"
# Valuable cases: non-staff, too old, wrong status, allowed.
# Four lines of code can encode four different business risks.Revisa código no cubierto por consecuencia. Un formateador de logs no cubierto no es igual a una denegación de permisos no cubierta. El mutation testing puede revelar assertions que nunca notan comportamiento cambiado, pero úsalo primero en reglas core; es una herramienta de diagnóstico, no otra métrica de vanidad.
Ten este árbol de decisión junto al test runner.
# choose the test
pure rule → unit test
SQL / transaction / constraint → real database integration test
third-party request shape → adapter contract test
critical user journey → a small end-to-end test
# write the test
Arrange the smallest world → Act once → Assert observable behavior
normal case + refusal + awkward boundary
# when it fails
run it alone → read full diff → reproduce twice → inspect first useful frame
passes alone? check shared state, order, clock, random seed, ports
# reject these shortcuts
sleep for synchronization · force-exit leaked handles · mock the database
blind snapshot updates · retries that hide flakes · 100% coverage worshipMi línea dura: los tests existen para hacer el cambio más seguro, no para poner un dashboard más verde. Borra un test frágil que solo repite la implementación. Añade un test afilado alrededor del comportamiento que se rompió el martes pasado. Ejecuta infraestructura real donde su comportamiento es parte de tu promesa. Cuando un fallo no puede decirte qué promesa se rompió, reescribe el test antes de añadir otro.