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
37 28. Necesitamos testear la API completa sin romper la BD
Los endpoints tocan Postgres: ¿tests contra qué base de datos?
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:
- 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. - 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.
- 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_URLapuntando ataskflow_test - D El truco de la transacción con rollback: tests aislados, rápidos, sin limpieza
- TS supertest · Py
TestClient· Gohttptest— 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
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.texten cada endpoint que toca usuarios — la regresión de seguridad más barata que existe (cap. 26). - Tests de permisos con dos fixtures:
useryotherUser— 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
- ¿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?
- Escribe (en pseudocódigo) el test “viewer no puede mutar” para
PATCH /projects/{id}— ¿qué fixtures necesita? - ¿Por qué testear
res.body.error.code === "VALIDATION"y nores.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.
- 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).
- Fixtures:
projectconowner(admin) yviewer(miembro con rol viewer) + tokens de ambos. El test:PATCHcon 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ó). - Porque
codees el contrato estable ymessagees 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.