Testing Astro Apps
Astro’s zero-JS-by-default architecture changes how you think about testing. Most of a typical Astro site is static HTML rendered at build time, so a large portion of your “behavior” can be verified by simply asserting on rendered markup — no headless browser required. Where you do ship interactivity through islands, you layer in component and end-to-end tests. A solid Astro testing strategy combines fast unit tests for logic, component tests for rendered output, and a handful of full browser tests for the journeys that matter most.
The testing pyramid for Astro
The classic testing pyramid maps cleanly onto Astro projects. The bulk of your tests should be cheap and fast; only a few should drive a real browser.
| Layer | Tool | What it verifies | Speed |
|---|---|---|---|
| Unit | Vitest | Pure functions, utils, content schemas, loaders | Fastest |
| Component | Vitest + Container API | A .astro component renders the right HTML | Fast |
| Island | Vitest + Testing Library | Hydrated React/Vue/Svelte interactivity | Medium |
| End-to-end | Playwright | Full pages, navigation, forms, real hydration | Slowest |
Because Astro components render to a string on the server, you can test most of them without a DOM at all. That makes the middle of the pyramid unusually fast compared to client-heavy frameworks.
Unit testing logic
Anything that is plain JavaScript or TypeScript — formatters, parsers, content-collection helpers, data transforms — is tested exactly as you would in any Node project. Vitest is the recommended runner because it understands Vite’s resolution, which Astro is built on, so your astro:* aliases and TS paths resolve without extra config.
// src/utils/slugify.ts
export function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// src/utils/slugify.test.ts
import { describe, it, expect } from 'vitest';
import { slugify } from './slugify';
describe('slugify', () => {
it('lowercases and dashes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('strips leading and trailing dashes', () => {
expect(slugify(' --Astro 5! ')).toBe('astro-5');
});
});
npx vitest run
Output:
✓ src/utils/slugify.test.ts (2 tests) 3ms
Test Files 1 passed (1)
Tests 2 passed (2)
Component testing with the Container API
To assert on what a .astro component actually renders, use Astro’s official Container API. It renders a component to an HTML string in isolation, with no dev server and no browser, so you can make precise assertions on the markup, attributes, and slots.
---
// src/components/Badge.astro
interface Props { label: string; tone?: 'info' | 'warn'; }
const { label, tone = 'info' } = Astro.props;
---
<span class={`badge badge--${tone}`}>{label}</span>
// 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 and tone class', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(Badge, {
props: { label: 'New', tone: 'warn' },
});
expect(html).toContain('New');
expect(html).toContain('badge--warn');
});
This keeps server-rendered components honest without paying the cost of a browser. It is ideal for verifying conditional rendering, slot composition, and prop-driven output.
Tip: The Container API renders the static, server output of a component. It does not hydrate islands or run
client:*directives — for that, reach for end-to-end tests.
Testing islands
An island built in React, Vue, or Svelte is tested with that framework’s own tooling (for example Testing Library), since the hydrated component is plain framework code. Mount it directly in a jsdom environment and drive the interaction:
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { expect, test } from 'vitest';
import Counter from './Counter';
test('increments on click', async () => {
render(<Counter start={0} />);
await fireEvent.click(screen.getByRole('button'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
The island file is the same module Astro hydrates, so a passing unit test here gives real confidence about runtime behavior.
End-to-end testing
End-to-end tests are the only layer that proves the full stack works together: routing, hydration, client:* directives firing, forms submitting, and adapters serving real responses. Playwright is the recommended choice. It boots your built site (or dev server), navigates as a user would, and waits for hydration before asserting.
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test('counter island hydrates and works', async ({ page }) => {
await page.goto('/');
const button = page.getByRole('button', { name: /count/i });
await button.click();
await expect(button).toHaveText(/Count: 1/);
});
npm run build && npx playwright test
Run E2E against a production build whenever possible — it exercises the same output your users receive, including the chosen adapter and any prerendered pages.
Best practices
- Push tests down the pyramid: prefer fast Container API tests over browser tests whenever you only need to check rendered HTML.
- Use Vitest for everything non-browser; it shares Astro’s Vite config so
astro:contentand path aliases resolve with zero setup. - Test content-collection schemas and loaders as plain units — a bad Zod schema fails the build, but a unit test catches it sooner with a clearer message.
- Reserve Playwright for journeys that depend on hydration, navigation, or forms; keep the E2E suite small and run it against
astro buildoutput. - Co-locate
*.test.tsfiles beside the code they cover so tests move with the component. - Treat islands as ordinary framework components and test them with that framework’s Testing Library, not through the browser.