Skip to content
Astro as testing 4 min read

Component Testing with the Container API

Because Astro components render to a string on the server with zero JavaScript by default, you can test most of them without a browser or a DOM. The Container API is Astro’s official way to do exactly that: spin up a lightweight, in-memory rendering environment, hand it a .astro component plus props and slots, and get back the HTML it produced. You then assert on that markup the same way you would inspect any string. This makes component tests fast, deterministic, and a natural fit for the middle of the Astro testing pyramid.

What the Container API is

The Container API lives in astro/container. It exposes a factory, AstroContainer.create(), that builds an isolated renderer with the same rendering pipeline Astro uses at build time. From that container you call renderToString() (to get HTML as a string) or renderToResponse() (to get a standard Response object, useful when you care about status codes or headers).

The Container API is experimental. The import path and signatures are stable enough for everyday testing, but expect minor changes between Astro releases — pin your Astro version in test suites that depend on it.

Because it renders on the server, the output is the static HTML your component emits. Client-side hydration does not run here, so islands appear as their server-rendered markup plus the directives that would trigger hydration in a real page. To test the interactive behavior of an island after hydration, reach for Playwright or a Testing Library island test instead.

Rendering your first component

Consider a small component that takes props and renders markup:

---
// src/components/Badge.astro
interface Props {
  label: string;
  tone?: 'info' | 'success' | 'danger';
}
const { label, tone = 'info' } = Astro.props;
---
<span class={`badge badge-${tone}`} role="status">{label}</span>

A Vitest test renders it through a container and asserts on the resulting string:

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

test('renders the label with the default tone', async () => {
  const container = await AstroContainer.create();
  const html = await container.renderToString(Badge, {
    props: { label: 'New' },
  });

  expect(html).toContain('New');
  expect(html).toContain('class="badge badge-info"');
  expect(html).toContain('role="status"');
});

test('applies the requested tone', async () => {
  const container = await AstroContainer.create();
  const html = await container.renderToString(Badge, {
    props: { label: 'Removed', tone: 'danger' },
  });

  expect(html).toContain('badge-danger');
});

Run it with Vitest:

npx vitest run src/components/Badge.test.ts

Output:

 ✓ src/components/Badge.test.ts (2 tests) 28ms
   ✓ renders the label with the default tone
   ✓ applies the requested tone

 Test Files  1 passed (1)
      Tests  2 passed (2)

Passing slots, params, and a request

renderToString and renderToResponse accept the same options object. Beyond props, you can supply named and default slots, route params, and a custom Request.

OptionTypePurpose
propsRecord<string, any>Values for Astro.props
slotsRecord<string, string>Slot content (default for the unnamed slot)
paramsRecord<string, string>Dynamic route params for Astro.params
requestRequestDrives Astro.request, Astro.url, and headers
partialbooleanRender without the full document wrapper

Slots are passed as strings keyed by slot name:

import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from './Card.astro';

test('fills named and default slots', async () => {
  const container = await AstroContainer.create();
  const html = await container.renderToString(Card, {
    slots: {
      default: '<p>Body copy</p>',
      title: 'Pricing',
    },
  });

  expect(html).toContain('<p>Body copy</p>');
  expect(html).toContain('Pricing');
});

Asserting on a Response

When a component or page sets a status code or redirect, use renderToResponse to inspect the full Response:

import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import NotFound from '../pages/404.astro';

test('404 page returns the right markup', async () => {
  const container = await AstroContainer.create();
  const response = await container.renderToResponse(NotFound);
  const body = await response.text();

  expect(response.status).toBe(200); // 404.astro itself renders 200 in tests
  expect(body).toContain('Page not found');
});

Rendering components that use integrations

If your component relies on a renderer — for example, it embeds a React or Vue island — the container needs that renderer registered. Use AstroContainer.create() and add the renderer so the island’s server-rendered markup is produced correctly:

import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { loadRenderers } from 'astro:container';
import { getContainerRenderer } from '@astrojs/react';
import { expect, test } from 'vitest';
import Layout from './Layout.astro';

test('renders an embedded React island', async () => {
  const renderers = await loadRenderers([getContainerRenderer()]);
  const container = await AstroContainer.create({ renderers });
  const html = await container.renderToString(Layout);

  expect(html).toContain('astro-island');
});

Note the astro:container import only resolves inside the Astro/Vite test environment — run these tests with the Vitest config produced by getViteConfig from astro/config so the virtual modules resolve.

Best practices

  • Create a fresh container per test (or per file) to keep rendering state isolated and tests order-independent.
  • Assert on meaningful, stable markup — text content, ARIA roles, and key attributes — rather than brittle whole-HTML snapshots.
  • Use the Container API for output correctness; use Playwright for anything that depends on hydration, navigation, or real user interaction.
  • Register renderers only for the integrations a test actually uses to keep setup minimal and fast.
  • Prefer partial: true when testing a fragment so you do not assert against boilerplate document markup.
  • Pin your Astro version in test suites, since the Container API is still experimental.
Last updated June 14, 2026
Was this helpful?