wandres.dev
TESTING · Vitest, Container, Playwright

Testear endpoints y actions: invocar el handler y asertar la Response

Un endpoint es una función de una petición a una respuesta, así que probarlo no exige servidor: se importa el handler del verbo, se construye una Request, se invoca y se afirma sobre la Response —status, cabeceras y cuerpo—. Cómo cubrir las ramas de error y el 405, cómo blindar el borde hostil validando la entrada, y cómo llevar la misma disciplina a las actions: probar el esquema de input con safeParse, ejercitar el handler y afirmar el ActionError que emiten los fallos de negocio.

⏱ 16 min

Ya lo establecimos al estudiar los endpoints bajo demanda: un handler es, despojado de jerga, una función de una Request a una Response. Esa definición tan austera es también una promesa para el testing, porque una función pura de entrada y salida es lo más fácil de probar que existe. No hace falta arrancar un servidor, ni abrir un puerto, ni esperar a la red: basta con importar el handler del verbo, fabricar la petición que quieres simular, invocarlo y afirmar sobre la respuesta que devuelve. La misma economía se traslada a las actions, que son handlers con un esquema de validación delante; probarlas es probar dos cosas separables —que la entrada se valida y que la lógica hace lo correcto— y esa separación es justo lo que hace la prueba nítida.

🎯 Al terminar esta lección sabrás
  • Invocar un handler de endpoint importándolo y pasándole un APIContext simulado.
  • Construir una Request de prueba y afirmar sobre status, cabeceras y cuerpo de la Response.
  • Cubrir las ramas de error: entrada inválida con 400 y verbo sin handler con 405.
  • Probar una action en dos planos: el esquema de input con safeParse y el handler.

Un endpoint es una función: invócala directamente

Como un endpoint exporta una función por verbo, un test la importa y la llama como a cualquier otra. El único argumento es el APIContext; en un test unitario no necesitas uno completo, solo los campos que el handler realmente lee, así que construyes un contexto parcial y lo pasas.

// src/pages/api/notas.test.ts
import { describe, it, expect } from 'vitest';
import { GET } from './notas';

describe('GET /api/notas', () => {
  it('responde 200 con una lista', async () => {
    const context = { url: new URL('http://local/api/notas') };
    const res = await GET(context as any);

    expect(res.status).toBe(200);
    const cuerpo = await res.json();
    expect(Array.isArray(cuerpo)).toBe(true);
  });
});

El as any sobre un contexto parcial es un pragmatismo consciente, no una dejadez: afirmas que el handler solo usa url, y si un día toca cookies o clientAddress, el propio test fallará y te obligará a ampliar el doble. Lo esencial es que aquí no hay magia de framework: GET es una función, la invocas con una entrada controlada y examinas su salida. Toda la potencia del modelo de endpoints —que sea (context) => Response— se cobra ahora en forma de testabilidad trivial.

Construir la petición y asertar la Response

Para los verbos que llevan cuerpo —POST, PUT, PATCH— la entrada interesante viaja en una Request, que construyes con la API estándar de la plataforma: método, cabeceras y cuerpo serializado. La respuesta es una Response, y sobre ella afirmas las tres cosas que importan: el estado, las cabeceras y el cuerpo.

import { POST } from './notas';

it('crea una nota y responde 201', async () => {
  const request = new Request('http://local/api/notas', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ titulo: 'Comprar pan' }),
  });

  const res = await POST({ request } as any);

  expect(res.status).toBe(201);
  expect(res.headers.get('content-type')).toContain('application/json');
  const nota = await res.json();
  expect(nota.titulo).toBe('Comprar pan');
});

Estas tres afirmaciones —status, cabecera y cuerpo— son el contrato completo de una respuesta HTTP, y probarlas por separado te da diagnósticos precisos: si falla el status sabes que la rama de control se equivocó; si falla la cabecera, que el tipo de contenido no es el pactado; si falla el cuerpo, que la serialización o los datos son otros. Un endpoint bien probado documenta, test a test, qué promete devolver ante cada clase de petición.

flowchart TD
B[construir Request y context parcial] --> H[invocar handler del verbo]
H --> RES[Response]
RES --> S[assert status]
RES --> C[assert cabeceras]
RES --> D[await json y assert cuerpo]
style B fill:#89b4fa,color:#11111b
style RES fill:#cba6f7,color:#11111b
style D fill:#a6e3a1,color:#11111b

Validación de entrada: el borde hostil

Un endpoint bajo demanda es una superficie pública que cualquiera golpea con lo que quiera, así que sus ramas de rechazo merecen tanta prueba como su camino feliz —o más, porque son las que se olvidan—. Los dos casos canónicos son la entrada inválida, que debe responder 400, y el verbo no soportado, que Astro contesta con 405.

it('rechaza un cuerpo invalido con 400', async () => {
  const request = new Request('http://local/api/notas', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({}), // falta titulo
  });

  const res = await POST({ request } as any);
  expect(res.status).toBe(400);
});
⚠️
Prueba el rechazo antes que la aceptación

Existe un sesgo humano en el testing: probamos con entusiasmo que las cosas funcionan y con desgana que fallan bien. Pero en un endpoint las rutas de error son la primera línea de defensa, y son precisamente las que un refactor descuida sin que nadie lo note hasta que un cliente manda basura y provoca un 500. Escribe el test del cuerpo malformado, el del campo que falta, el del tipo equivocado. Cada uno fija una promesa: “ante esta clase de entrada mala, respondo 400 y no toco la base de datos”. Un endpoint que solo tiene tests del camino feliz está probado a medias, y la mitad que falta es la peligrosa.

Testear una action: esquema y handler

Una action es un handler con un esquema de input delante. Esa arquitectura sugiere probarla en dos planos separables. El primero es la validación: si extraes el esquema de Zod a su propio módulo, lo pruebas directamente con safeParse, sin tocar Astro, afirmando qué entradas acepta y cuáles rechaza.

// src/actions/schemas.ts
import { z } from 'astro:schema';
export const suscribirInput = z.object({ email: z.string().email() });

// src/actions/schemas.test.ts
import { suscribirInput } from './schemas';

it('rechaza un email malformado', () => {
  const r = suscribirInput.safeParse({ email: 'no-es-email' });
  expect(r.success).toBe(false);
});

El segundo plano es el comportamiento: la lógica que corre cuando la entrada es válida. La disciplina que mejor escala es que el handler delegue en funciones normales, y que esas funciones —lógica pura, como la del primer nivel de la pirámide— se prueben aisladas. Cuando el fallo es de negocio —un correo ya suscrito, un permiso denegado—, la action lanza un ActionError con su code, y eso se afirma esperando el rechazo.

import { yaExiste } from './suscripciones';

it('lanza CONFLICT si el email ya existe', async () => {
  await expect(yaExiste('repetido@correo.co'))
    .resolves.toBe(true);
  // el handler traduce ese true en un ActionError code CONFLICT
});
📥

input con safeParse

Extrae el esquema Zod a un modulo y pruebalo aislado: que acepte lo valido y rechace lo malo.

⚙️

handler delegado

Que el handler llame a funciones puras y prueba esas funciones como logica del primer nivel.

🚨

ActionError

Afirma el fallo de negocio esperando el rechazo y comprobando el code del error.

🔗

Cableado completo

Reserva un test de integracion con la Container API o e2e para la action ya conectada.

Para probar la action ya cableada —input, validación y handler juntos, tal como la llama el cliente— el instrumento es la Container API con getActionContext, o directamente un test de extremo a extremo si el flujo cruza el navegador. Reservar ese caso caro para un puñado de recorridos, y cubrir con tests baratos el esquema y la lógica, es aplicar a las actions la misma política de la pirámide que gobierna todo lo demás.

La entrada no confiable es el verdadero sujeto del test

Cuando pruebas un endpoint o una action, es tentador creer que el sujeto del test es tu lógica —la consulta a la base, el cálculo, la respuesta que construyes—. Pero el sujeto más importante, el que de verdad decide si tu servidor sobrevive al mundo, es la entrada que no controlas. Un handler vive en la frontera entre tu sistema y todos los demás, y del otro lado hay navegadores con bugs, clientes malintencionados, integraciones que cambian de forma sin avisar y usuarios que pegan lo que no deben en el campo que no era. Toda la disciplina de este nivel —construir peticiones a mano, forzar cuerpos malformados, exigir el 400 antes que el 201, separar la validación del comportamiento— converge en una sola idea: tratar la entrada como territorio hostil y probar que tu código la desarma antes de confiar en ella. Por eso las actions ponen el esquema delante del handler y no dentro; por eso los endpoints deben responder con un código de estado honesto ante lo que rechazan. Un test que solo comprueba el camino feliz asume un mundo educado que no existe, y su verde es una falsa tranquilidad: dice que tu código funciona cuando todo va bien, que es exactamente cuando no necesitabas ayuda. El valor real de esta clase de pruebas está en el reverso: en fijar, caso a caso, cómo se comporta tu frontera cuando la empujan, la engañan o la rompen. Un backend robusto no es el que hace bien lo esperado, sino el que rechaza con elegancia lo inesperado, y solo lo sabes con certeza si lo has probado a propósito con la entrada que temías recibir.

⚔️ Prueba la frontera de tu backend
  1. Importa el handler GET de un endpoint tuyo, pásale un APIContext parcial y afirma status y cuerpo de la Response con await res.json().
  2. Construye una Request de POST con cuerpo JSON, invoca el handler y comprueba que el camino feliz responde 201 con la cabecera y el cuerpo esperados.
  3. Fuerza un cuerpo malformado y verifica que respondes 400 sin tocar tu almacenamiento; añade un caso que confirme el 405 de un verbo sin handler.
  4. Extrae el esquema de input de una action a su módulo, pruébalo con safeParse para una entrada válida y una inválida, y afirma con rejects el ActionError de un fallo de negocio.