flowchart TB
subgraph Pirámide de tests
E["E2E: pocos, caros — flujos críticos completos"]
I["Integración: endpoints + BD real (cap. 28)"]
U["Unitarios: muchos, baratos — reglas del servicio"]
end
U --> I --> E
36 27. Necesitamos confiar en el código que no estamos mirando
Sin tests, el miedo es la arquitectura

Cada refactor rompe algo en otro lugar, y sin tests te enteras por un usuario. La suite no existe para “cumplir cobertura”: existe para que puedas cambiar el código sin miedo — muchos tests baratos en la capa de servicio, pocos caros en los bordes, y cada test respondiendo “¿qué rompería si esto se borra?”.
36.1 El problema
El refactor del cap. 7 separó handler de servicio — ¿y quién garantiza que complete_task sigue dando el punto una sola vez, que el título vacío se rechaza, que la prioridad 99 no entra? “Lo probé a mano” es la respuesta que dura hasta el próximo commit de otro. El miedo a tocar código viejo no es prudencia: es la deuda de no tener tests.
Un commit es una foto guardada del código con un mensaje que dice por qué cambió. La historia del proyecto es una cadena de commits: puedes volver a cualquiera, ver qué cambió y quién lo hizo.
36.2 Cómo lo resuelve un equipo
Un equipo serio testea donde viven las decisiones: la capa de servicio (reglas de negocio puras, sin HTTP ni BD si se puede) — ahí un test es una función, un input, un assert. La pirámide: muchos unitarios rápidos, menos de integración, pocos end-to-end. Y el criterio que evita los tests de juguete: cada test debe matar un bug posible — si borras una línea del servicio y ningún test se queja, esa línea no está testeada.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
36.3 Conceptos nuevos
- U Qué testear y qué no: la pirámide en backend (servicios > endpoints > e2e)
- TS vitest · Py pytest +
conftest· Gogo test+ table-driven tests (estilo exportable) - U Fixtures:
beforeEachvs conftest vs helpers — preparar mundo, ejecutar, limpiar - U Parametrize/table tests: un test, veinte casos
36.4 La explicación visual
La base ancha: el servicio es donde viven las reglas (“no borrar tarea con subtareas”, “el punto se da una vez”) — ahí cada test es barato y preciso. El e2e certifica que las piezas conectan, no que cada regla existe.
36.5 Implementación
Tests del servicio — unidad pura en los tres estilos:
import { describe, it, expect } from "vitest";
describe("createTask", () => {
it("rejects empty title", () => {
expect(() => createTask(userId, "")).toThrow(ValidationError);
});
it.each([[" "], ["a".repeat(201)]])("rejects bad title: %j", (title) => {
expect(() => createTask(userId, title)).toThrow(ValidationError);
});
it("trims and persists a valid title", () => {
const t = createTask(userId, " pay rent ");
expect(t.title).toBe("pay rent");
expect(t.done).toBe(false);
});
});import pytest
class TestCreateTask:
def test_rejects_empty_title(self):
with pytest.raises(ValidationError):
create_task(user_id=1, title="")
@pytest.mark.parametrize("title", [" ", "a" * 201])
def test_rejects_bad_title(self, title):
with pytest.raises(ValidationError):
create_task(user_id=1, title=title)
def test_trims_and_persists(self):
t = create_task(user_id=1, title=" pay rent ")
assert t.title == "pay rent"
assert t.done is Falsefunc TestCreateTask(t *testing.T) {
cases := []struct {
name string
title string
wantErr bool
}{
{"empty title", "", true},
{"blank title", " ", true},
{"too long", strings.Repeat("a", 201), true},
{"valid", "pay rent", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
task, err := createTask(1, tc.title) // table-driven: one test, all cases
if tc.wantErr {
if err == nil { t.Fatal("expected error") }
return
}
if err != nil { t.Fatal(err) }
if task.Title != "pay rent" || task.Done { t.Fatalf("bad task: %+v", task) }
})
}
}Escribe la suite de tests unitarios del servicio de tareas en mi stack
[vitest/pytest/go test]. Reglas a cubrir: título requerido (vacío,
solo-espacios, >200 chars), trim antes de persistir, prioridad por
defecto 3, done inicia false, complete_task solo otorga el punto UNA vez
(segundo complete → conflicto), no se puede borrar tarea con subtareas.
Usa [it.each/parametrize/table-driven] para los casos límite, fixtures
para el usuario de prueba, y nombres de test que lean como
especificación ("rejects_empty_title").
36.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: los tres estilos son el mismo test — mundo preparado → acción → assert. La tabla de casos (parametrize / table-driven) es la técnica universal para los límites: un test, veinte inputs.
- TS/vitest:
beforeEachprepara el mundo por test — cada test nace en un estado limpio y conocido. - Python/pytest:
conftest.pydeclara fixtures que se inyectan por nombre —def test_x(db, user)recibe lo que necesita sin llamarlo. - Go: sin fixtures de framework — helpers
newTestService(t)que construyen el mundo explícitamente. Más verboso, más visible.
El estilo Go (table-driven) es exportable a cualquier lenguaje: cuando tus casos son datos + expectativa, una tabla > diez funciones copiadas.
36.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Tests que testean el framework | expect(res.status).toBe(200) sin lógica propia |
Testear tu regla: “viewer → 403”, no “Express devuelve 200” |
| Acoplar al implementación | El test llama funciones privadas | Test contra el contrato: input → output/efecto |
| Fixtures compartidos mutables | “El user del test anterior ya existe” | Mundo limpio por test (beforeEach/fixture scope) |
| Tests que siempre pasan | expect(true).toBe(true) tras un refactor |
El test debe fallar si borras la línea que protege |
| Cobertura como meta | “Lleguemos a 90%” | Cobertura donde importa: reglas, fronteras de error, auth |
36.8 Buenas prácticas
- Nombres que son especificación:
rejects_empty_title,second_complete_returns_conflict— leer la suite es leer las reglas del negocio. - Un assert de significado por test (o pocos): el test que verifica 14 cosas no dice cuál falló.
- El test como documento de diseño (TDD, Ap. G tickets 41–42): escribir “debería rechazar X” antes de tener X es diseñar la API desde su consumidor.
- Rápido o no se corre: la suite unitaria corre en segundos — si tarda minutos, los devs la saltan y es decoración.
EXTRA Cuando un roadmap o una entrevista dice “tipos de pruebas”, se refiere a —
- Pruebas unitarias — una función aislada, sin BD ni red (cap-27)
- Pruebas de integración — la API contra la BD real (cap-28)
- Pruebas funcionales / e2e — el sistema completo como un usuario (cap-27, la punta cara de la pirámide)
La pirámide del libro ordena exactamente esto: muchas unitarias, las suficientes de integración, pocas funcionales.
36.9 Ejercicio
- Ordena por dónde testear: “el email único se enforza”, “el JSON parsea”, “un viewer no puede borrar”, “la título se trimea”. ¿Cuál va en unit, cuál en integración, cuál no se testea?
- Este test existe:
it("works", () => { expect(createTask(1,"x").id).toBeTruthy() }). ¿Qué le falta para ser un buen test? - Escribe la tabla de casos para
complete_task: todos los estados desde los que se puede llamar y qué debe pasar.
Los roles agrupan permisos: viewer lee, editor escribe, admin gestiona. RBAC = el permiso depende del rol que tienes en ese recurso — puedes ser admin de un equipo y viewer de otro.
El estado es todo lo que el programa recuerda: las variables, la sesión, lo que muestra la UI. Stateless (sin estado) = el servidor no recuerda nada entre requests — cada request trae todo lo necesario.
36.10 Mini reto
Escribir el test que hubiera detectado tu último bug.
Ejercicio.
- “Título se trimea” → unit (regla pura del servicio). “Email único” → integración (la constraint vive en la BD — cap. 28). “Viewer no puede borrar” → integración (necesita auth+roles reales). “El JSON parsea” → no se testea: es el framework, no tu código.
- Todo: no dice qué debería pasar ni qué bug mata. Un buen test nombra la regla (
creates_task_with_default_priority), verifica el significado (priority === 3,done === false), y falla si la regla se rompe — este test pasa aunque el servicio haga cualquier cosa que devuelva algo con id. estado inicial llamada esperado done=false, subtareas=0 complete done=true, punto+1 done=true complete 409 CONFLICT — ya completada done=false, subtareas pendientes complete 409 — subtareas abiertas tarea de otro usuario complete 404 (anti-enumeración)
Mini reto. El patrón es siempre el mismo: reproduce el bug como test que falla (it("returns 404 for foreign task") → hoy devuelve 200 con datos ajenos) → arregla el código → el test queda como guarda permanente. Cada bug real merece su test: la suite es la memoria de todos los errores que ya no puedes volver a cometer.
36.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una constraint es una regla que la BD hace cumplir: NOT NULL, UNIQUE, CHECK (price > 0), FOREIGN KEY. Es la última línea de defensa — el código puede tener bugs; la constraint no perdona.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
Una imagen es la plantilla inmutable de la que nacen contenedores: la foto del disco + cómo arrancar. Se construye en capas (Dockerfile), se versiona con tags, se publica en un registry.
TDD (Test-Driven Development) = escribir el test antes del código: primero defines qué debe pasar (rojo), luego lo haces pasar (verde), luego limpias. El test es la especificación que corre.
La cobertura es el % de tu código que los tests ejecutan. Útil como detector de “esto nadie lo prueba”; inútil como meta — 90% cubierto no significa que los tests afirmen algo correcto.