Testing de Software en 10 Minutos

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: ·

01Testea la frontera, no cada línea

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.

02Usa la pirámide de tests, pero rechaza el dogma

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 journeys
Mi regla

No 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.

03Arrange, Act, Assert: una razón para fallar

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 == 2250
Fallo real: lee ambos valores
AssertionError: assert 2249 == 2250
 +  where 2249 = Total(...).due_cents

Uno: 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.

04Aserta comportamiento; usa el double más pequeño y honesto

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.

05Controla el trabajo async y el tiempo

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();
});
Fallo real: Jest no terminó
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.

06Usa una base de datos real y contratos ejecutables

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 }
Fallo real: fixture no encontrada
fixture 'db' not found
available fixtures: capfd, caplog, capsys, monkeypatch, tmp_path

Uno: 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.

07Diagnostica un test fallido antes de editar código

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 -vv

No 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.

08Trata los tests inestables como defectos

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}
Corrige, no reintentes en silencio

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.

09La cobertura encuentra huecos; no demuestra calidad

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.

10Cheat sheet de testing de software

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 worship

Mi 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.

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.