36  27. Necesitamos confiar en el código que no estamos mirando

Sin tests, el miedo es la arquitectura

Parte 6 — Testing
NotaEn una frase

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 · Go go test + table-driven tests (estilo exportable)
  • U Fixtures: beforeEach vs conftest vs helpers — preparar mundo, ejecutar, limpiar
  • U Parametrize/table tests: un test, veinte casos

36.4 La explicación visual

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

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 False
func 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: beforeEach prepara el mundo por test — cada test nace en un estado limpio y conocido.
  • Python/pytest: conftest.py declara 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

  1. 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?
  2. Este test existe: it("works", () => { expect(createTask(1,"x").id).toBeTruthy() }). ¿Qué le falta para ser un buen test?
  3. 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.

  1. “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.
  2. 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.
  3. 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.

36.12 Lo que deberías saber hacer ahora