Testing & Tooling
Three layers of testing
Section titled “Three layers of testing”An Astro site is mostly server-rendered HTML with a few islands, which shapes how you test it. Three layers cover it:
| Layer | Tool | What it checks |
|---|---|---|
| Unit | Vitest | Plain logic — helpers, data transforms, schema parsing |
| Component | Astro Container API (+ Vitest) | A component renders the expected HTML for given props/slots |
| End-to-end | Playwright | Real pages in a real browser, including island interactivity |
The rule of thumb: push logic into plain functions and unit-test those, render-test the components whose output matters, and use a few end-to-end tests for the flows that involve real hydration and navigation.
Unit tests with Vitest
Section titled “Unit tests with Vitest”Because Astro runs on Vite, Vitest is the natural unit-test runner — same config, instant startup. Anything that’s plain TypeScript (a date formatter, a filter, a Zod schema) is tested the ordinary way:
export function formatDate(d: Date): string { return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });}import { expect, test } from 'vitest';import { formatDate } from '../lib/format';
test('formatDate renders a friendly date', () => { expect(formatDate(new Date('2026-01-15'))).toBe('Jan 15, 2026');});Component tests with the Container API
Section titled “Component tests with the Container API”To test that a .astro component renders the right HTML, use the Astro Container API — it renders a component to a string in isolation, no browser needed. It’s currently exposed as experimental_AstroContainer — still experimental, exactly as the name says, even though it’s stable enough to be Astro’s own recommended way to component-test:
The component under test:
---interface Props { title: string; href: string;}const { title, href } = Astro.props;---<a class="card" href={href}> <h3>{title}</h3></a>import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { expect, test } from 'vitest';import Card from '../components/Card.astro';
test('Card renders its title and href', async () => { const container = await AstroContainer.create(); const result = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' }, });
expect(result).toContain('Hello'); expect(result).toContain('href="/hello"');});You get the rendered HTML as a string and assert against it — fast, and it exercises the real component including its props and slots.
Container-testing a framework island
Section titled “Container-testing a framework island”renderToString’s real type signature only accepts an AstroComponentFactory — a plain .astro component — not a raw framework function component (verified directly: passing Counter.tsx straight to renderToString is a real astro check error, ts(2345): Argument of type '() => Element' is not assignable to parameter of type 'AstroComponentFactory'). The documented, type-safe pattern (straight from Astro’s own Container API reference) is to wrap the island in a thin .astro component and test that:
The island under test — a plain Preact counter with no required props:
import { useState } from 'preact/hooks';
export default function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;}---import Counter from './Counter.tsx';---<Counter client:load />Rendering that wrapper still needs the framework’s own renderer registered first, via getContainerRenderer — imported from a subpath of that specific framework’s integration package. This course’s islands use Preact, so it’s @astrojs/preact/container-renderer (verified directly against the installed package’s own exports map, which lists ./container-renderer) — not @astrojs/react/container-renderer, which only exists if you actually installed @astrojs/react:
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { getContainerRenderer } from '@astrojs/preact/container-renderer';import { loadRenderers } from 'astro:container';import { expect, test } from 'vitest';import CounterWrapper from '../components/CounterWrapper.astro';
test('Counter island renders its initial count via the Container API', async () => { const renderers = await loadRenderers([getContainerRenderer()]); const container = await AstroContainer.create({ renderers }); const html = await container.renderToString(CounterWrapper); expect(html).toContain('Count: 0');});$ npx vitest run src/__tests__/counter-island.test.ts Test Files 1 passed (1) Tests 1 passed (1)Without renderers registered, the Container API has no way to render the <Counter client:load /> inside the wrapper at all — loadRenderers plus the framework-specific getContainerRenderer is what makes that possible, and wrapping in a real .astro file (rather than passing Counter.tsx directly) is what keeps renderToString’s own types happy.
End-to-end with Playwright
Section titled “End-to-end with Playwright”Container tests render HTML but don’t run islands. For behavior that depends on hydration — clicking an interactive island, client-side navigation with <ClientRouter />, a form submission — use Playwright (or Cypress) to drive a real browser against your built site:
import { test, expect } from '@playwright/test';
test('counter island increments on click', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'Increment' }).click(); await expect(page.getByText('Count: 1')).toBeVisible();});flowchart LR logic["plain logic"] --> vitest["Vitest unit test"] comp["component HTML output"] --> container["Container API test"] flow["hydrated flows and navigation"] --> pw["Playwright e2e"]
astro check: what it checks, and its exit code
Section titled “astro check: what it checks, and its exit code”astro check type-checks .astro, .ts, and .tsx files with real diagnostics at three severities — error, warning, hint — and prints a summary line (Result (N files): - X error(s) - Y warning(s) - Z hint(s)). Proven against the installed 7.3.5: a clean project exits 0; a project with at least one error exits 1:
$ npx astro check; echo "exit: $?"Result (100 files):- 0 errors- 0 warnings- 3 hintsexit: 0
# after introducing one real type error:$ npx astro check; echo "exit: $?"Result (100 files):- 1 error- 0 warnings- 3 hintsexit: 1By default, only error-severity diagnostics fail the command — --minimumFailingSeverity (default "error") controls that threshold, and can be tightened to "warning" or "hint" if you want CI to fail on those too. That’s why astro check && astro build is the wiring worth using: warnings and hints won’t silently block a deploy unless you explicitly ask them to.
ESLint for Astro
Section titled “ESLint for Astro”eslint-plugin-astro (currently 3.2.1, verified via npm view eslint-plugin-astro version) lints .astro files — frontmatter, template, directives, and (with eslint-plugin-jsx-a11y/-x installed alongside) accessibility rules. It’s flat-config only, and its own README calls it “in the experimental stages of development” — worth knowing before you wire failing lint into a required CI gate. The real flat-config shape from its own docs:
import eslintPluginAstro from 'eslint-plugin-astro';
export default [ ...eslintPluginAstro.configs.recommended, { rules: { // override/add rules here, e.g.: // 'astro/no-set-html-directive': 'error' }, },];configs.recommended is a spread-in array (multiple flat-config objects), not a single object — a generated snippet that writes eslintPluginAstro.configs.recommended without the ... spread will produce a broken config array.
The Dev Toolbar
Section titled “The Dev Toolbar”In development, Astro shows a Dev Toolbar at the bottom of the page — it flags accessibility issues, shows which components are islands (and their hydration directives), and hosts plugins for auditing and debugging. It’s the fastest way to see what actually became an island and catch a11y problems while you build. It only runs in dev and never ships to production.
Astro 7 also made the dev server friendlier to automation: it can run in a background mode, detects when an AI coding agent is driving it, and can emit structured JSON logs so tools (and agents) read errors without scraping terminal output. Two small API notes for test code: framework container renderers are now imported from a subpath specific to the framework you actually installed (proven above: @astrojs/preact/container-renderer on this course’s Preact-based project), and view-transition tests should listen for the string event names ('astro:before-swap') because the exported constants were removed.
Reading AI-generated code
Section titled “Reading AI-generated code”A generated test snippet for an Astro project is worth double-checking:
- Does a Container API test pass a raw framework component (
.tsx/.vue/.svelte) straight torenderToString, instead of wrapping it in a thin.astrocomponent first? Proven above:renderToString’s own type is(component: AstroComponentFactory, ...), so passing a raw framework component is a realastro checkerror (ts(2345)) even before you get to renderers. - Does a Container API test skip
loadRenderers/getContainerRendererentirely when rendering something that embeds a framework island? Proven above with the real runtime error:NoMatchingRenderer: Unable to render 'Counter'. No valid renderer was found for this file extension.— not a silent empty string. - Does it import
getContainerRendererfrom@astrojs/react/container-rendereron a project that uses Preact (or Vue, or Svelte)? The subpath is per-framework — proven above, this course’s own islands need@astrojs/preact/container-renderer, and the wrong one is simply an unresolved-module error, not a generic “any framework” import. - Does it claim
experimental_AstroContaineris “basically stable now, the name is just legacy”? It’s genuinely still experimental — the API can change in a minor version, which matters if you’re deciding how much test infrastructure to build on top of it. - Does a CI config wire
astro checkin and assume any warning fails the build? Proven above: onlyerror-severity diagnostics fail by default (--minimumFailingSeverity); a warning-heavy but error-free project still exits0. - Does an
eslint.config.jssnippet writerules: eslintPluginAstro.configs.recommended(no spread) instead of spreading it into the config array?configs.recommendedis itself an array of flat-config objects, not a rules map.
🐛 Spot the bug
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { expect, test } from 'vitest';import Badge from '../components/Badge.tsx';
test('Badge renders', async () => {const container = await AstroContainer.create();const html = await container.renderToString(Badge, { props: { label: 'New' } });expect(html).toContain('New');});Show the answer
Two separate bugs stacked here. First, Badge.tsx is a framework component (a .tsx file, not .astro), but renderToString is typed to accept only AstroComponentFactory — passing Badge directly is a real astro check error, ts(2345): Argument of type '() => Element' is not assignable to parameter of type 'AstroComponentFactory', proven above with the exact same shape of mistake on Counter. The documented fix is to wrap it in a thin .astro component first (<Badge {...props} /> inside a real .astro file) and render that — exactly the CounterWrapper pattern shown above. Second, even past that: this container was created with no renderers at all, so once a framework component is actually reached during rendering, it fails for real at runtime too — proven above with the exact error: NoMatchingRenderer: Unable to render 'Counter'. No valid renderer was found for this file extension. Both problems need the same fix: wrap in .astro, then loadRenderers([getContainerRenderer()]) from the specific framework package (@astrojs/preact/container-renderer here) and pass { renderers } to AstroContainer.create().
Mental Model
Section titled “Mental Model”Match the test to what can actually break at that layer. A pure function has no framework involvement at all — Vitest, directly. A .astro component’s only failure mode is “wrong HTML for these props/slots” — the Container API answers exactly that, and (proven above) needs zero browser and zero extra setup for a plain .astro file, but does need a registered renderer the moment the component isn’t .astro at all. Hydration — the one thing neither of those layers can exercise, because neither actually runs client JavaScript — is Playwright’s entire reason to exist. Reach for the cheapest layer that can actually observe the failure you’re worried about.
Trade-offs
Section titled “Trade-offs”| Vitest (unit) | Container API (component) | Playwright (e2e) | |
|---|---|---|---|
| Needs a browser | No | No | Yes |
| Runs island JavaScript | No | No | Yes |
| Speed | Fastest | Fast | Slowest |
| Catches | Logic bugs | Wrong HTML for given props/slots | Hydration, navigation, real user flows |
| Stability of the API itself | Stable | experimental_AstroContainer — proven above, still experimental |
Stable |
Common Misconceptions
Section titled “Common Misconceptions”- “A Container API test proves an island is interactive.” It proves the initial HTML is right — proven above,
CounterrendersCount: 0, but nothing in that test ever clicks the button or runspreact/hookscode; that’s Playwright’s job. - “Any framework’s
getContainerRendererimport works for any project.” Proven above: the subpath is tied to whichever@astrojs/<framework>package is actually installed —@astrojs/react/container-rendererdoesn’t exist (and can’t be imported) on a Preact-only project. - “
astro checkfailing in CI means there’s a type error.” Proven above: by default onlyerror-severity diagnostics fail the command; a project full of warnings and hints still exits0unless--minimumFailingSeverityis tightened. - “
experimental_AstroContaineris just an oddly-named stable API at this point.” It’s real: Astro’s own naming says experimental, meaning its shape can still change across minor versions — worth a note if a team is investing heavily in Container-API-based test infrastructure.