🎯 Meta: dominar la disciplina que distingue a un profesional. Un backend sin tests es una bomba de tiempo: cada cambio puede romper algo sin que te enteres hasta que un usuario lo sufre. Con tests, cambias con confianza.
Aquí unificamos lo que viste en cada capítulo y le damos teoría y método.
9.1 · ¿Por qué testear? (el argumento que convence)
Sin tests, cada vez que tocas el código haces esto:
Cambio algo → ¿rompí algo? → pruebo a mano 20 pantallas → se me olvida una → 💥 producciónCon tests:
Cambio algo → "npm test" → ✅ 143 verdes → despliego tranquiloLos tests son una red de seguridad y documentación viva: leyendo un test entiendes qué hace (y qué debe hacer) el código, siempre actualizado porque, si mienten, fallan.
🧠 El miedo a cambiar código es la señal. Si te da miedo tocar algo "no vaya a romperse", es que te faltan tests. Los tests te devuelven la valentía para refactorizar y mejorar.
9.2 · La pirámide de tests
No todos los tests son iguales. La proporción sana:
╱╲
╱ ╲ E2E / Aceptación
╱ E2E╲ Pocos. Lentos. Prueban el sistema entero.
╱──────╲ (navegador → API → BD → respuesta)
╱ ╲
╱Integración╲ Algunos. Prueban módulos juntos
╱────────────╲ (endpoint → BD real de test).
╱ ╲
╱ Unitarios ╲ MUCHOS. Rápidos. Prueban una función
╱──────────────────╲ o clase aislada (con mocks).| Tipo | Qué prueba | Velocidad | Cuántos |
|---|---|---|---|
| Unitario | Una función/clase aislada | ⚡ Milisegundos | Muchísimos (70%) |
| Integración | Varias piezas juntas (ej. endpoint + BD) | 🚶 Segundos | Bastantes (20%) |
| E2E | El sistema completo como un usuario | 🐢 Lentos | Pocos (10%) |
⚠️ Anti-patrón "cono de helado": muchos E2E y pocos unitarios. Los E2E son lentos y frágiles; si dependes solo de ellos, tu suite tarda 40 minutos y falla por cualquier cosa. Base ancha de unitarios, punta fina de E2E.
9.3 · Anatomía de un buen test: AAA
Todo test se estructura en tres partes (Arrange, Act, Assert):
Arrange (Preparar) → monta el escenario: datos, objetos, mocks
Act (Actuar) → ejecuta LA cosa que pruebas (una sola acción)
Assert (Afirmar) → comprueba que el resultado es el esperadoEjemplo (Python, pero el patrón es universal):
def test_calcular_total_con_igv():
# Arrange
carrito = Carrito()
carrito.agregar(Producto("Mouse", precio=100), cantidad=2)
# Act
total = carrito.total_con_igv()
# Assert
assert total == 236.0 # (100 * 2) + 18% IGVReglas de un buen test:
- Un test, una cosa. Si falla, sabes exactamente qué se rompió.
- Nombre descriptivo:
test_rechaza_precio_negativo, notest_1. - Independiente: no depende del orden ni de otros tests.
- Rápido y determinista: mismo input → mismo resultado, siempre (nada de
randomo fechas reales sin controlar).
9.4 · Mocks, stubs y fakes (dobles de prueba)
Para testear una unidad aislada, reemplazas sus dependencias por "dobles":
| Doble | Qué es | Cuándo |
|---|---|---|
| Stub | Devuelve respuestas fijas | "cuando pidas el usuario 1, devuelve este" |
| Mock | Verifica que se le llamó (y cómo) | "comprueba que se envió el email" |
| Fake | Implementación simplificada real | Una BD en memoria en vez de PostgreSQL |
Ya los usaste: el repoFake de Go (cap. 08), el prismaMock de NestJS (cap. 06), el dependency_overrides de FastAPI (cap. 05). Todos son la misma idea: sustituir lo lento/externo por algo controlado.
🧠 ¿Por qué son posibles los mocks? Porque programaste contra interfaces/abstracciones, no contra implementaciones concretas. Si tu servicio depende de una interfaz
Repositorio, puedes darle una falsa en el test. Eso es la "D" de SOLID (cap. 10) pagando dividendos. El código testeable y el código bien diseñado son el mismo código.
⚠️ No sobre-mockees. Si mockeas absolutamente todo, tu test comprueba tus mocks, no tu código. Mockea lo externo y lento (BD, APIs, email); usa lo real para lo demás.
9.5 · TDD — Test Driven Development
Escribir el test antes que el código. El ciclo Red-Green-Refactor:
🔴 RED Escribe un test que falla (aún no existe el código)
🟢 GREEN Escribe el código MÍNIMO para que pase
🔵 REFACTOR Mejora el código sin romper el test
↺ RepiteEjemplo mental para "calcular descuento":
1. 🔴 test: descuento(100, 10%) debe dar 90 → falla (no existe la función)
2. 🟢 escribe: def descuento(p, d): return p * (1 - d) → pasa
3. 🔵 añade validación, nombres claros… test sigue verde
4. 🔴 nuevo test: descuento con % negativo debe lanzar error → falla
5. 🟢 añade la validación → pasa💡 TDD no es obligatorio, pero enseña muchísimo. Te fuerza a pensar en qué debe hacer el código antes de cómo. Y garantiza cobertura desde el minuto uno. Practícalo al menos en katas y lógica de negocio pura; para CRUD trivial es menos necesario.
9.6 · Qué testear (y qué no)
SÍ testea:
- Lógica de negocio (cálculos, reglas, validaciones): el corazón de tu app.
- Casos límite: valores 0, negativos, vacíos, nulos, máximos.
- Los caminos de error (no solo el "camino feliz").
- Bugs que arreglas → escribe primero el test que los reproduce (test de regresión).
NO hace falta testear:
- Código de terceros (el framework, el ORM: ya está testeado).
- Getters/setters triviales sin lógica.
- Configuración.
🧠 Regla de oro: testea el comportamiento (qué hace), no la implementación (cómo lo hace). Un test que se rompe cada vez que refactorizas (sin cambiar el comportamiento) es un mal test: está acoplado a los detalles internos.
9.7 · Cobertura de código (coverage)
La cobertura mide qué % de tu código ejecutan los tests:
# Cada stack tiene su comando:
php artisan test --coverage # Laravel
pytest --cov=app # Flask / FastAPI
npm run test:cov # NestJS
bun test --coverage # Elysia
go test -cover ./... # Go⚠️ La cobertura es una guía, no una meta. 100% de cobertura no significa "sin bugs": puedes ejecutar una línea sin comprobar que hace lo correcto. Y perseguir el 100% lleva a tests inútiles de getters. Apunta a ~70-85% con tests significativos, y cubre bien la lógica crítica. Un 60% con buenos tests vale más que un 95% de tests vacíos.
9.8 · Tests de integración con BD real
Los unitarios usan mocks; los de integración prueban contra una BD de verdad (pero de prueba). La técnica moderna: Testcontainers (levanta un PostgreSQL en Docker solo para los tests):
# Ejemplo conceptual (Python + testcontainers)
from testcontainers.postgres import PostgresContainer
def test_guardar_producto_real():
with PostgresContainer("postgres:18") as pg:
engine = create_engine(pg.get_connection_url())
# ...corre migraciones, inserta, verifica contra la BD REAL...Alternativa más simple: una BD dedicada de test + RefreshDatabase/transacciones que se revierten tras cada test (así queda limpia).
💡 Tip: aísla cada test envolviéndolo en una transacción que se revierte (
ROLLBACK) al final. Así cada test ve una BD limpia sin tener que borrar y recrear tablas. Laravel (RefreshDatabase), pytest (fixtures) y otros lo facilitan.
9.9 · Tests E2E (extremo a extremo)
Prueban el sistema completo como lo haría un usuario o cliente real. Para APIs, "usuario" = otro programa que hace peticiones HTTP:
Test E2E de API: lanza la app real → POST /login → guarda token
→ POST /productos con el token → GET /productos
→ verifica que el producto está → DELETE → verifica que ya noPara apps con frontend se usan herramientas como Playwright o Cypress que manejan un navegador real. Pero para backend puro, los E2E son peticiones HTTP encadenadas (como el supertest de NestJS o TestClient de FastAPI que ya viste).
⚠️ Los E2E son valiosos pero frágiles y lentos. Ten pocos, que cubran los flujos críticos (login, compra, pago). No intentes cubrir cada caso con E2E: para eso están los unitarios.
9.10 · Tests en CI (automatización)
Los tests solo sirven si se ejecutan siempre. Se automatizan en CI (Integración Continua): cada git push dispara los tests automáticamente. Ejemplo con GitHub Actions:
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres: # BD de prueba en el CI
image: postgres:18
env:
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4 # (o setup-go, setup-python, etc.)
with: { node-version: 24 }
- run: npm ci
- run: npm test # si falla, el PR se marca en rojo 🔴🔗 CI/CD completo (con despliegue automático) lo verás en el capítulo 14. Aquí lo importante: ningún código llega a producción sin pasar los tests. Esa es la regla que protege tu producto.
9.11 · Tabla resumen: testing por stack
| Stack | Runner | Unit | Integración/E2E | Comando |
|---|---|---|---|---|
| Laravel | Pest/PHPUnit | ✅ | Feature tests + RefreshDatabase | php artisan test |
| Flask | pytest | ✅ | test_client + SQLite memoria | pytest |
| FastAPI | pytest | ✅ | TestClient + dependency_overrides | pytest |
| NestJS | Jest | ✅ | supertest (e2e) + mocks | npm test |
| Elysia | bun:test | ✅ | app.handle(request) | bun test |
| Go | testing | ✅ | fakes + testcontainers | go test ./... |
9.12 · Más allá de la cobertura: mutation testing y contract testing
Dos técnicas de nivel senior que responden a preguntas que la cobertura normal no puede contestar.
Mutation testing — "¿mis tests detectarían un bug real?" La herramienta introduce cambios pequeños y deliberados en tu código (mutantes: cambia un > por un >=, un + por un -, borra una línea) y vuelve a correr tus tests. Si siguen en verde, tienes un test que no detecta ese error — cobertura alta, pero ciega.
# Ejemplo con Stryker (JS/TS) o mutmut (Python)
npx stryker run # muta el código y mide qué % de mutantes "mata" tu suite
mutmut run # equivalente en Python💡 Un "mutation score" del 80% significa que el 80% de los bugs simulados fueron detectados por algún test. Es una métrica más honesta que la cobertura de líneas — cara de calcular (corre la suite muchas veces), así que resérvala para módulos críticos (cálculos de dinero, autorización), no para todo el proyecto en cada push.
Contract testing — cuando dos servicios se comunican y no puedes probarlos juntos siempre. Si tu API la consume otro equipo (u otro microservicio), un test de contrato (ej. Pact) verifica que tu respuesta sigue cumpliendo lo que el consumidor espera, sin tener que levantar ambos sistemas juntos en cada CI:
Consumidor (frontend/otro servicio) define lo que espera:
"espero que GET /pedidos/1 devuelva { id, total, estado }"
↓ (se publica como "contrato")
Proveedor (tu API) corre ese contrato contra SU implementación real en su propio CI
→ si tu respuesta ya no incluye "estado", el contrato falla ANTES de llegar a producción🧠 Resuelve el mismo problema que el versionado de APIs (apéndice D.7) desde otro ángulo: detecta romper un contrato con un consumidor real antes del deploy, en vez de enterarte cuando ese consumidor falla en producción. Imprescindible en arquitecturas con varios servicios/equipos (cap. 11.6); innecesario en un monolito con un solo consumidor (tu propio frontend en el mismo repo).
✅ Ejercicio del capítulo
Toma una de tus APIs del blog (la que quieras) y llévala a un nivel de testing serio:
1. Unitarios: testea TODA la lógica de negocio con mocks (validaciones,
cálculos, reglas). Incluye casos límite y de error.
2. Integración: al menos 3 tests contra una BD de prueba.
3. E2E: 1 flujo completo (registro → login → crear artículo → comentar → borrar).
4. Practica TDD: añade una feature nueva (ej. "likes") escribiendo el test PRIMERO.
5. Mide la cobertura y sube la lógica crítica por encima del 80%.
6. Monta un workflow de GitHub Actions que corra los tests en cada push.Con esto, tu código deja de ser "espero que funcione" y pasa a ser "sé que funciona".
💡 Pistas de la solución
- Para el TDD del punto 4: escribe el test de "un artículo con 3 likes muestra
likes: 3" ANTES de tener el campo — debe fallar en rojo (ni siquiera compila o el campo no existe), luego añades lo mínimo para que pase, luego refactorizas con el test como red de seguridad. - El E2E del punto 3 es el único que debe tocar TODAS las capas reales (BD de prueba incluida) — si mockeas la BD ahí, dejas de estar probando lo que de verdad ocurre en producción.
- Si tu cobertura global no llega al 80% pero la lógica crítica (cálculos, validaciones, reglas de negocio) sí, eso está bien — recuerda H.6/cap. 09.9: cobertura alta en getters y configuración no vale lo mismo que cobertura en la lógica que de verdad puede romperse.
🧠 Autoevaluación
El unitario aísla UNA pieza de lógica para testearla rápido y de forma determinista, sin depender de una BD real ni de la red. El de integración existe precisamente para verificar que esa pieza funciona bien CON sus dependencias reales (BD, otros servicios) — mockear ahí sería no probar lo que el test dice probar.
Porque obliga a diseñar la interfaz desde el punto de vista de quien la usa (no de quien la implementa), y porque un test escrito después tiende a confirmar lo que el código ya hace en vez de especificar lo que debería hacer — es fácil escribir, sin darte cuenta, un test que pasa "porque el código está así", no porque el comportamiento sea correcto.
La cobertura mide qué líneas se ejecutaron durante los tests, no si las aserciones verifican el comportamiento correcto. Un test que llama a una función y no comprueba nada del resultado cuenta como "cobertura" pero no detecta ningún bug. Es un indicador de dónde te falta probar, no una garantía de calidad.
Porque un aviso que se puede ignorar tarde o temprano se ignora — y basta con que un código roto llegue a main una vez para que el resto del equipo herede un problema. Bloquear el merge convierte "los tests pasan" en un requisito, no en una sugerencia.
Que la mayoría de tus tests ejecutan el código (por eso la cobertura es alta) pero no afirman nada preciso sobre el resultado — si el código cambiara de forma incorrecta, muchos tests seguirían pasando igual. La cobertura mide qué líneas se tocaron; el mutation score mide si tus aserciones detectarían un bug real introducido en esas líneas.
Siguiente: 10-solid-clean-code.md — escribir código que otros (y tu yo futuro) puedan entender.