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.
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.tsimport { 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.
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);});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.tsimport { z } from 'astro:schema';export const suscribirInput = z.object({ email: z.string().email() });// src/actions/schemas.test.tsimport { 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});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.