37  28. Necesitamos testear la API completa sin romper la BD

Los endpoints tocan Postgres: ¿tests contra qué base de datos?

NotaEn una frase

El test unitario cubre la regla; el test de API cubre el contrato completo: ruta + middleware + auth + validación + query + respuesta, con Postgres de verdad. Las dos preguntas prácticas: ¿contra qué base de datos (una dedicada de test, con rollback por test), y ¿cómo se ejercita la API sin levantar el servidor (supertest/TestClient/httptest)?

37.1 El problema

Los tests del cap. 27 prueban create_task directo — pero el bug del cap. 17 (la tarea ajena que devolvía 200) vivía en la integración: el servicio funcionaba, la query no filtraba por dueño. Un bug de esos solo lo caza un test que pase por el endpoint de verdad: auth real, query real, BD real. Y ahí aparecen las preguntas de siempre: ¿los tests corren contra mi BD de dev? ¿qué pasa con los datos que crean?

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.

Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.

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.

37.2 Cómo lo resuelve un equipo

Un equipo serio monta la infra de test una vez y la reutiliza siempre:

  1. BD de test dedicada: taskflow_test — otra base en el mismo Postgres de Docker, con las mismas migraciones. Los tests destruyen datos: nunca contra dev, jamás contra prod.
  2. Aislamiento por transacción: cada test corre dentro de una transacción que hace rollback al terminar — el test escribe, verifica, y la BD queda como si nada. Aislamiento gratis, sin scripts de limpieza, y rápido.
  3. La API sin socket: supertest/TestClient/httptest llaman al

Una migración es un cambio versionado del esquema: un archivo que dice “crea esta columna” (up) y “bórrala” (down). Son el historial Git de la estructura de la BD — se aplican en orden y no se editan una vez aplicadas.

Una transacción es un grupo de operaciones que se confirman juntas o no se confirma ninguna: transferir dinero = restar de A y sumar a B. Si falla a la mitad sin transacción, el dinero desapareció.

router directo en memoria — HTTP real, sin puerto, sin npm start paralelo.

37.3 Conceptos nuevos

  • U BD de test dedicada: DATABASE_URL apuntando a taskflow_test
  • D El truco de la transacción con rollback: tests aislados, rápidos, sin limpieza
  • TS supertest · Py TestClient · Go httptest — ejercitar la API sin socket real
  • U Testear lo protegido: fabricar usuarios, sesiones y tokens en fixtures
  • U Tests que son documentación: “viewer no puede mutar → 403” permanente

37.4 La explicación visual

flowchart LR
  T[Test] -->|request en memoria| R[Router + middleware real]
  R --> S[Servicio real]
  S --> DB[(taskflow_test)]
  T -->|BEGIN antes / ROLLBACK después| DB

Todo es real menos el socket y la durabilidad de los datos — por eso el test corre rápido y deja la BD limpia sola.

37.5 Implementación

El setup + un test que atraviesa auth → validación → BD → contrato:

// setup: DATABASE_URL=postgres://localhost:5432/taskflow_test (env de test)
import request from "supertest";

describe("POST /tasks", () => {
  it("creates a task for the authenticated user", async () => {
    const { token, user } = await seedUser();           // fixture: user+token
    const res = await request(app)
      .post("/tasks")
      .set("Cookie", `token=${token}`)
      .send({ title: "pay rent" });
    expect(res.status).toBe(201);
    expect(res.body.title).toBe("pay rent");
    expect(res.body.public_id).toMatch(/^[0-9a-f-]{36}$/);
    expect(res.body.password_hash).toBeUndefined();     // the leak test
  });

  it("rejects invalid body with 422 contract", async () => {
    const res = await request(app).post("/tasks")
      .set("Cookie", `token=${token}`).send({ title: "" });
    expect(res.status).toBe(422);
    expect(res.body.error.code).toBe("VALIDATION");
  });
});
from fastapi.testclient import TestClient

@pytest.fixture
def client(db_tx):                    # db_tx: connection inside a transaction
    app.dependency_overrides[get_db] = lambda: db_tx
    yield TestClient(app)             # test runs; fixture rolls back after

def test_create_task(client, auth_headers):
    res = client.post("/tasks", json={"title": "pay rent"}, headers=auth_headers)
    assert res.status_code == 201
    assert res.json()["title"] == "pay rent"
    assert "password_hash" not in res.text            # the leak test

def test_validation_error_contract(client, auth_headers):
    res = client.post("/tasks", json={"title": ""}, headers=auth_headers)
    assert res.status_code == 422
    assert res.json()["error"]["code"] == "VALIDATION"
func TestCreateTask(t *testing.T) {
    tx := beginTx(t)                    // test transaction
    defer tx.Rollback()                 // cleanup is a rollback — free isolation
    user, token := seedUser(t, tx)
    srv := httptest.NewServer(newRouter(testDeps{tx: tx}))
    defer srv.Close()

    req, _ := http.NewRequest("POST", srv.URL+"/tasks",
        strings.NewReader(`{"title":"pay rent"}`))
    req.Header.Set("Cookie", "token="+token)
    req.Header.Set("Content-Type", "application/json")
    res, _ := http.DefaultClient.Do(req)

    if res.StatusCode != 201 { t.Fatalf("got %d", res.StatusCode) }
    var body map[string]any
    json.NewDecoder(res.Body).Decode(&body)
    if body["password_hash"] != nil { t.Fatal("leaks internal field") } // the leak test
}
Monta tests de integración para mi API [Express/FastAPI/net-http]
contra Postgres. Requisitos: BD dedicada taskflow_test con las mismas
migraciones, aislamiento por transacción con rollback por test (sin
scripts de limpieza), ejercitar el router sin levantar socket
(supertest/TestClient/httptest), fixtures para crear usuario+token
válido y un segundo usuario, y tests de contrato: create→201 con
public_id, validación→422 {error:{code}}, sin auth→401, recurso
ajeno→404, viewer→403, y un test que verifique que password_hash NUNCA
aparece en ninguna respuesta.

37.6 ¿Por qué cada stack lo hace así?

Lo único que necesitas llevarte: el patrón es uno — BD de test + rollback por test + router en memoria. Las herramientas cambian, la arquitectura del test no.

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.

  • Rollback por test vs truncate por test: el rollback es más rápido y no toca secuencias — pero solo funciona si tu código acepta la conexión/transacción inyectada (otra paga de la DI del cap. 16). Truncate entre tests es la alternativa simple cuando inyectar la tx es imposible.
  • Fixtures de identidad: seedUser() crea usuario + sesión + token en la misma tx del test — el token es real (pasa por tu middleware de verdad), no un mock de auth. El segundo fixture (otherUser) es el que permite escribir el test más importante: GET /tasks/{ajena} → 404.

37.7 Errores comunes

Error Por qué pasa Fix
Tests contra la BD de dev “Es la que está corriendo” taskflow_test dedicada — los tests borran datos, es su trabajo
Limpieza manual por test DELETE FROM tasks al final Rollback de la tx — la BD queda sola
Mockear la auth req.user = fake Token real de fixture — si no, el middleware no se testea
Tests dependientes del orden “El test 3 usa lo del test 2” Cada test construye su mundo — orden aleatorio debe pasar
Testear solo el camino feliz “201 funciona” El contrato incluye los errores: 401/403/404/422 son API también

37.8 Buenas prácticas

  • El test de la fuga: "password_hash" not in res.text en cada endpoint que toca usuarios — la regresión de seguridad más barata que existe (cap. 26).
  • Tests de permisos con dos fixtures: user y otherUser — el 404-a-lo-ajeno y el 403-de-viewer son tests, no esperanzas.
  • El contrato se testea, no la implementación: status + código de error + campos del body — el test no sabe qué query corriste.
  • CI corre la suite: BD de test en el pipeline (un Postgres en Docker de un comando) — los tests que solo corren “en mi máquina” no existen para el equipo.

37.9 Ejercicio

  1. ¿Por qué el rollback-por-test necesita que la app reciba la conexión inyectada? ¿Qué pasa si el servicio abre su propia conexión del pool?
  2. Escribe (en pseudocódigo) el test “viewer no puede mutar” para PATCH /projects/{id} — ¿qué fixtures necesita?
  3. ¿Por qué testear res.body.error.code === "VALIDATION" y no res.body.error.message === "title is required"?

Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.

37.10 Mini reto

Demostrar con un test que el doble-submit devuelve 409 tras éxito.

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

Ejercicio.

  1. Si el servicio pide su propia conexión al pool, sus writes viven en otra transacción — que sí hace COMMIT y queda persistida: el rollback del test no la alcanza y la BD se ensucia. La DI de la conexión es el precio de entrada del patrón (cap. 16 lo paga).
  2. Fixtures: project con owner (admin) y viewer (miembro con rol viewer) + tokens de ambos. El test: PATCH con token de viewer → 403 (miembro sin permiso — no 404: sí pertenece al proyecto); el mismo PATCH con token de owner → 200 (el contraste prueba que la ruta funciona y el rol es lo que cambió).
  3. Porque code es el contrato estable y message es texto para humanos que cambiará (cap. 25): el test contra el mensaje se rompe cuando alguien mejora la redacción — falso negativo que enseña a ignorar los tests.

Mini reto. POST /tasks con el mismo payload dos veces (o con Idempotency-Key, cap. 22): primera → 201; segunda → 409 {"error":{"code":"CONFLICT"}} (o 200 con el mismo recurso si la diseñaste idempotente). El test documenta la decisión — cualquiera de las dos, pero decidida y testeada, no accidental.

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

Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.

Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.

Una sesión es la conversación continuada entre tú y el servidor: HTTP no recuerda nada entre requests, así que la sesión (vía cookie o token) es el “pulso de mano” que te identifica en cada llamada.

Un puerto es una puerta numerada dentro de una computadora: la IP llega al edificio, el puerto al departamento. :3000 = “la app que escucha en el departamento 3000”. Postgres suele usar 5432, HTTP el 80, HTTPS el 443.

localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.

La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.

37.12 Lo que deberías saber hacer ahora