wandres.dev
TESTING · Vitest, Container, Playwright

La Container API: renderizar componentes .astro a HTML y asertarlo

La Container API experimental —experimental_AstroContainer— renderiza un componente .astro a una cadena de HTML dentro del proceso de test, sin navegador ni servidor. Crear un contenedor con AstroContainer.create, invocar renderToString con props y slots, simular el APIContext cuando el componente lee la petición, cargar los renderers de framework con loadRenderers y getContainerRenderer para probar islas, y asertar sobre la salida con fragmentos y consultas estructurales en lugar de snapshots frágiles.

⏱ 17 min

Un componente .astro es, en el fondo, una función que produce HTML. Durante mucho tiempo esa función solo se podía ejercitar arrancando el proyecto entero; la Container API cambia eso al exponer el motor de renderizado de Astro como una pieza que puedes instanciar dentro de un test. Con experimental_AstroContainer creas un contenedor, le pasas un componente con sus props y sus slots, y recibes la cadena de HTML que ese componente habría emitido en el servidor —todo en el mismo proceso, sin navegador, en milisegundos—. Es la herramienta que ocupa el centro de la pirámide: más real que un test de la lógica suelta, muchísimo más barata que un recorrido de extremo a extremo.

🎯 Al terminar esta lección sabrás
  • Crear un contenedor con AstroContainer.create y renderizar con renderToString.
  • Pasar props y slots, y simular el APIContext cuando el componente lee la petición.
  • Cargar renderers de framework con loadRenderers y getContainerRenderer para probar islas.
  • Asertar sobre el HTML con fragmentos y consultas estructurales, huyendo del snapshot frágil.

Crear un contenedor y renderizar a cadena

El punto de partida es importar el contenedor experimental y crearlo de forma asíncrona. Una vez creado, renderToString recibe el componente y devuelve el HTML que produce su render de servidor.

// src/components/Boton.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import Boton from './Boton.astro';

describe('Boton', () => {
  it('renderiza el texto del slot dentro de un button', async () => {
    const container = await AstroContainer.create();
    const html = await container.renderToString(Boton, {
      slots: { default: 'Enviar' },
    });
    expect(html).toContain('<button');
    expect(html).toContain('Enviar');
  });
});

Lo que obtienes es exactamente la salida del servidor: el HTML que el navegador recibiría en la respuesta inicial, antes de cualquier hidratación. Esa precisión define el alcance de la herramienta. La Container API prueba lo que Astro emite, no lo que el JavaScript del cliente hace después; para eso último —un botón que reacciona a un clic— hará falta un navegador de verdad. Aquí verificamos la estructura, el contenido y los atributos del render inicial, que es justo la porción de un componente que Astro controla y que un cambio tuyo puede romper.

flowchart TD
C[AstroContainer create] --> R[renderToString Componente props slots]
R --> H[cadena de HTML del servidor]
H --> P[parsear o buscar fragmento]
P --> A[expect toContain o consulta estructural]
style C fill:#cba6f7,color:#11111b
style H fill:#89b4fa,color:#11111b
style A fill:#a6e3a1,color:#11111b

Props, slots y el contexto de la petición

Un componente rara vez vive aislado: recibe props que gobiernan su salida y slots que inyectan contenido. renderToString acepta ambos en su segundo argumento, de modo que puedes ejercitar cada rama del componente pasándole entradas distintas. Los slots con nombre se pasan por su clave; el slot por defecto, bajo default.

const html = await container.renderToString(Tarjeta, {
  props: { titulo: 'Astro 7', destacada: true },
  slots: {
    default: '<p>Cuerpo de la tarjeta</p>',
    pie: 'Publicado hoy',
  },
});
expect(html).toContain('destacada');
expect(html).toContain('Publicado hoy');

Cuando el componente lee la petición —Astro.request, Astro.url, Astro.params o una cookie— el contenedor te deja simular ese contexto. renderToString admite un request con una Request construida a mano y un objeto params para los segmentos dinámicos, así que puedes probar cómo se comporta el mismo componente ante distintas URLs o cabeceras sin levantar un servidor. Esta capacidad convierte a la Container API en algo más que un renderizador de piezas tontas: prueba componentes que ramifican según el estado de la petición, que es donde suele esconderse la lógica interesante.

const html = await container.renderToString(Saludo, {
  params: { lang: 'es' },
  request: new Request('http://local/es/hola', {
    headers: { 'accept-language': 'es' },
  }),
});
expect(html).toContain('Hola');

Aquí el mismo componente recibiría otra salida ante params.lang distinto o ante otra cabecera accept-language, y cada variante es un caso de test independiente. Simular la petición te permite recorrer, en milisegundos y sin servidor, todas las ramas que en producción dependerían de la URL o de las cabeceras del visitante.

Componentes de framework: cargar renderers

Por defecto el contenedor sabe renderizar .astro, pero no una isla de React, Vue o Svelte, porque cada framework aporta su propio renderer de servidor. Para probar esos componentes se cargan sus renderers explícitamente con loadRenderers, importado del módulo virtual astro:container, y getContainerRenderer, que exporta cada integración.

import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { loadRenderers } from 'astro:container';
import { getContainerRenderer } from '@astrojs/react';
import Contador from './Contador';

const renderers = await loadRenderers([getContainerRenderer()]);
const container = await AstroContainer.create({ renderers });

const html = await container.renderToString(Contador, {
  props: { inicial: 3 },
});
expect(html).toContain('3');

Conviene recordar el matiz de la lección anterior sobre el alcance: aunque el componente lleve una directiva client:load en su uso real, la Container API produce solo el HTML de servidor —el marcado inicial y los islotes marcados para hidratar—, no ejecuta el runtime del cliente. Es decir, aquí compruebas que la isla se renderiza con el estado inicial correcto; comprobar que reacciona a un clic es territorio de playwright. Distinguir ambas afirmaciones evita la frustración de esperar interactividad de una herramienta que, por diseño, solo emite texto.

💡
renderToResponse cuando importan las cabeceras

Junto a renderToString, el contenedor ofrece renderToResponse, que devuelve un objeto Response completo en vez de una cadena. Úsalo cuando lo que quieres afirmar no es solo el cuerpo sino el estado o las cabeceras: un componente de página que fija una cookie, que responde con un status distinto o que redirige. Con la Response en la mano aplicas las mismas afirmaciones que usarás para los endpoints —response.status, response.headers.get, await response.text— y unificas así el modo de probar páginas y rutas de API bajo un único vocabulario.

Asertar sobre el HTML sin caer en el snapshot

Tener el HTML como cadena invita a la tentación del snapshot gigante, que ya rechazamos: rompe ante cualquier retoque y enseña al equipo a actualizarlo a ciegas. La alternativa robusta es afirmar sobre fragmentos significativos o, mejor aún, parsear la cadena y consultarla como un árbol. Una librería ligera como linkedom convierte el HTML en un DOM que interrogas con querySelector, de modo que tus afirmaciones hablen de estructura y no de texto literal.

import { parseHTML } from 'linkedom';

const html = await container.renderToString(Menu, {
  props: { items: ['Inicio', 'Blog'] },
});
const { document } = parseHTML(html);

const enlaces = document.querySelectorAll('nav a');
expect(enlaces.length).toBe(2);
expect(enlaces[0].getAttribute('href')).toBe('/');
expect(enlaces[1].textContent).toBe('Blog');
🏗️

AstroContainer.create

Instancia el motor de render en el proceso. Pasale renderers para probar islas de framework.

🧵

renderToString

Recibe props, slots, request y params; devuelve el HTML de servidor del componente.

📦

renderToResponse

Devuelve un Response completo cuando importan status y cabeceras, no solo el cuerpo.

🔍

Consulta estructural

Parsea el HTML y afirma con querySelector; huye del snapshot indiscriminado.

Consultar el árbol en lugar de comparar cadenas hace tus tests resilientes al ruido y precisos en la señal. Un cambio de clase o un reordenamiento de atributos no los rompe; en cambio, si desaparece un enlace o cambia un href, la afirmación falla justo donde debe. Esa es la diferencia entre un test que documenta el contrato del componente y uno que fotografía su apariencia de hoy.

Probar el render es probar una función, no una pantalla

La Container API encierra una idea que reordena cómo pensamos el testing de componentes en Astro: un componente de servidor no es una pantalla, es una función pura de props y slots a HTML. Durante años, probar la interfaz significó, casi por inercia, arrancar un navegador, esperar a que pintara y hurgar en el DOM vivo, con toda la lentitud y la intermitencia que eso arrastra. Astro puede permitirse algo más limpio precisamente por su arquitectura: como el grueso del componente es render de servidor determinista —las mismas props producen siempre el mismo HTML—, puedes ejercitarlo como ejercitas cualquier función, con entradas controladas y salidas verificables, sin la parafernalia del navegador. Eso desplaza una enorme cantidad de confianza desde la cúspide cara de la pirámide hacia su franja media y barata: comprobar que un menú pinta los enlaces correctos, que una tarjeta refleja su estado destacado, que una página fija la cabecera esperada, todo ello en milisegundos y sin escamas. La disciplina que exige a cambio es la de no pedirle a esta herramienta lo que no da: no prueba la hidratación, no prueba el clic, no prueba la animación; prueba el punto de partida, el HTML que el usuario recibe antes de que el cliente despierte. Interiorizar esa frontera —render de servidor aquí, interactividad allá arriba con Playwright— es lo que te permite repartir el esfuerzo con criterio, cargando en la Container API la mayoría de las afirmaciones sobre tus componentes y reservando el navegador para el puñado de comportamientos que solo existen cuando el JavaScript se despierta. Probar el render como una función no empobrece el test: lo hace rápido, estable y del tamaño exacto de la verdad que afirma.

⚔️ Renderiza y consulta el árbol
  1. Crea un contenedor con AstroContainer.create y afirma con toContain que un componente .astro sencillo emite el texto de su slot por defecto.
  2. Ejercita dos ramas del mismo componente pasando props distintas y comprueba que la salida cambia como esperas.
  3. Carga el renderer de un framework con loadRenderers y getContainerRenderer, renderiza una isla y verifica su estado inicial; razona por qué no puedes comprobar aquí su reacción a un clic.
  4. Parsea el HTML con linkedom y sustituye una afirmación de texto por una consulta estructural con querySelector; explica por qué es más resistente a los cambios cosméticos.