TL;DR — Astro’s Container API cannot coexist with a global jsdom environment because both need to control
TextEncoderin incompatible ways. The solution is to run tests in node and instantiate JSDOM locally, exactly like React Testing Library does internally. The result: semantic, user-first tests with zero friction.
Why This Matters
Astro is a framework designed to generate HTML on the server. That’s great for performance, but it raises an uncomfortable question: how do you test something that, by nature, doesn’t run in the browser?
If you come from the React world, you’re used to a very specific testing philosophy: the one proposed by Testing Library. The core idea is simple —
“The more your tests resemble the way your software is used, the more confidence they can give you.”
In React that means finding a button by its visible text, not by its CSS class or data-testid. Finding a form field by its label. Verifying that the user sees what they’re supposed to see.
In Astro, the community has long been using a different approach: render the component to an HTML string and do expect(html).toContain('<h2>'). It works. But it’s still a brittle check that looks at how something is implemented, not what the user experiences.
This article explains how we arrived at a renderAstro pattern that gives you full Testing Library support for .astro components and pages, and the — rather unusual — obstacles that had to be overcome to get there.
The Starting Point: Astro’s Container API
Since version 3.x, Astro exposes a tool called experimental_AstroContainer. Its purpose is simple: render any .astro component outside of a real HTTP request, directly in a Node process.
import { experimental_AstroContainer as Container } from "astro/container";
const container = await Container.create();
const html = await container.renderToString(MyComponent, {
props: { title: "Hello" },
});
// html = '<article><h2>Hello</h2></article>'
With this, the typical test approach looks like:
it("renders the title", async () => {
const html = await renderAstroComponent(FakeCard, {
props: { title: "Test title" },
});
expect(html).toContain("Test title");
expect(html).toMatch(/<h2[^>]*>.*Test title<\/h2>/s);
});
It works, and it’s a good start. But it has problems:
- Regular expressions are fragile. Any structural change breaks the test even if the user still sees the same thing.
- No semantics.
toContain('<article')says nothing about whether the element has the correct ARIA role. - You can’t test accessibility. Is that
<h2>accessible? Does the<ul>have anaria-label? With strings, you just don’t know.
What Testing Library Offers
Testing Library is not a testing library in the traditional sense. It’s a testing philosophy implemented as a library. Its core (@testing-library/dom) provides a set of queries to find elements in the DOM the same way a user or screen reader would:
| Query | What it finds |
|---|---|
getByRole('heading', { name: 'Title' }) | A heading whose accessible name is “Title” |
getByRole('link', { name: 'Read more' }) | A link with that text |
getByRole('list', { name: 'Tags' }) | A list with that aria-label |
getByText('Description') | Any element containing that text |
queryByRole(...) | Like getBy but returns null instead of throwing |
The fundamental difference compared to toContain('<h2') is that these queries:
- Verify semantics, not HTML syntax.
- Throw useful error messages when the element is not found.
- Encourage the use of accessibility attributes (
aria-label, ARIA roles, etc.). - Pair with
jest-dom, which adds matchers liketoBeInTheDocument(),toHaveAttribute()ortoHaveTextContent().
The First Attempt: Injecting into jsdom
The obvious idea is: if Testing Library needs a DOM, give it a DOM. Vitest lets you configure the test environment as jsdom, which simulates a full browser (with document, window, etc.). The plan was:
- Render with
Container→ HTML string. - Inject that HTML into
document.body. - Use Testing Library’s
screento query it.
// First attempt (DOES NOT WORK)
import { screen } from "@testing-library/dom";
export async function renderAstro(Component, props = {}) {
const container = await Container.create();
const html = await container.renderToString(Component, { props });
document.body.innerHTML = html; // ← requires jsdom
return { screen };
}
Clean. Elegant. And completely broken.
The Villain: esbuild and Cross-Realm instanceof
When you enable @vitest-environment jsdom in tests that use renderAstro, this error appears:
Error: Invariant violation:
"new TextEncoder().encode("") instanceof Uint8Array" is incorrectly false
This indicates that your JavaScript environment is broken.
You cannot use esbuild in this environment because esbuild
relies on this invariant.
To understand what’s happening, you need to understand three pieces:
1. Realms in JavaScript
In JavaScript, every execution context has its own realm: its own copy of Array, Object, Uint8Array, etc. An object created in realm A does not pass the instanceof check of realm B, even if they look visually identical.
// Realm A (native Node)
const a = new Uint8Array([1, 2, 3]);
a instanceof Uint8Array; // true in Realm A
// If Realm B (jsdom) overwrites Uint8Array...
a instanceof Uint8Array; // false in Realm B ← problem!
2. What jsdom Does
When you activate the jsdom environment, jsdom overwrites several native Node globals — including TextEncoder — with versions from its own realm. It does this to simulate a browser environment as faithfully as possible.
3. What esbuild Does
Astro Container uses esbuild internally to process code. When esbuild loads as a module, it runs this startup sanity check:
// node_modules/esbuild/lib/main.js ~line 201
if (!(new TextEncoder().encode("") instanceof Uint8Array)) {
throw new Error("Invariant violation: ...");
}
It’s a sanity check that says: “if I can’t create a Uint8Array as expected, this environment is broken and I can’t continue.”
The problem: jsdom has overwritten TextEncoder with its own version. The TextEncoder from jsdom’s realm produces Uint8Array from its own realm, which doesn’t pass the instanceof check from Node’s original realm. Result: esbuild explodes.
The Failed Patching Attempt
The logical fix is to think: restore TextEncoder in Vitest’s setup file.
// vitest.setup.ts
import { TextDecoder, TextEncoder } from "node:util";
Object.assign(global, { TextDecoder, TextEncoder });
The problem is timing. By the time Vitest runs the setup file, the jsdom worker has already replaced the globals. And when the worker starts evaluating modules (including esbuild, which loads when astro/container is imported), the module can be evaluated before the patch takes effect. Same error.
The Solution: Don’t Use a Global jsdom, Create a Local One
The key lies in how React Testing Library works under the hood. Its render(<Component />) function does not assume there is a global jsdom. It creates its own document and works with it. This isolates it from any environment contamination.
We can do exactly the same with Astro:
┌─────────────────────────────────────────────┐
│ Environment: Node (no global jsdom) │
│ │
│ 1. AstroContainer.renderToString() │
│ ↓ │
│ HTML string (pure SSR) │
│ ↓ │
│ 2. new JSDOM(html) │
│ ↓ │
│ local document (isolated realm) │
│ ↓ │
│ 3. getQueriesForElement(document.body) │
│ ↓ │
│ { getByRole, getByText, ... } │
└─────────────────────────────────────────────┘
JSDOM is created after esbuild has already initialized correctly. There’s no realm conflict because Node’s TextEncoder was intact when esbuild started.
@testing-library/dom exposes getQueriesForElement, which generates all Testing Library queries scoped to a specific DOM element. That element can be any HTMLElement, including the body of our local JSDOM.
The Implementation: renderAstro
// src/test/renderAstro.ts
import { experimental_AstroContainer as Container } from "astro/container";
import { type ContainerRenderOptions } from "astro/container";
import { JSDOM } from "jsdom";
import { getQueriesForElement, within } from "@testing-library/dom";
type AstroComponent = Parameters<Container["renderToString"]>[0];
export async function renderAstro(
Component: AstroComponent,
props: Record<string, unknown> = {},
options: ContainerRenderOptions = {},
) {
// 1. Render to HTML string in Node environment (esbuild happy)
const astroContainer = await Container.create();
const plainHtml = await astroContainer.renderToString(Component, {
props,
...options,
});
// 2. Create a local JSDOM document (does not pollute the global environment)
const dom = new JSDOM(plainHtml);
const { document } = dom.window;
const container = document.body;
// 3. Get Testing Library queries scoped to this document
const queries = getQueriesForElement(container);
return {
container, // HTMLBodyElement of the JSDOM document
document, // The full document (useful for document.title)
within, // To scope queries to a sub-element
plainHtml, // The original HTML in case you need it
...queries, // getByRole, getByText, queryByRole, getAllByRole...
};
}
20 lines. No special configuration. No environment juggling. And it works for both components (HTML fragments) and full pages (with <html>, <head>, <body>), because JSDOM parses both situations correctly.
In Practice: Testing a Component
Consider this FakeCard.astro component:
---
interface Props {
title: string;
description?: string;
href?: string;
tags?: string[];
}
const { title, description, href, tags = [] } = Astro.props;
---
<article>
<h2>{title}</h2>
{description && <p>{description}</p>}
{href && <a href={href}>Read more</a>}
{tags.length > 0 && (
<ul aria-label="Tags">
{tags.map((tag) => <li>{tag}</li>)}
</ul>
)}
</article>
The test with this pattern:
// FakeCard.test.ts
import { describe, it, expect } from "vitest";
import { within } from "@testing-library/dom";
import { renderAstro } from "@/test/renderAstro";
import FakeCard from "./FakeCard.astro";
describe("FakeCard", () => {
it("shows the title in a level 2 heading", async () => {
const { getByRole } = await renderAstro(FakeCard, {
title: "Test title",
});
expect(
getByRole("heading", { level: 2, name: "Test title" }),
).toBeInTheDocument();
});
it("does not show description when not provided", async () => {
const { queryByText } = await renderAstro(FakeCard, {
title: "Title only",
});
expect(queryByText(/description/i)).not.toBeInTheDocument();
});
it("shows link with correct href when href is provided", async () => {
const { getByRole } = await renderAstro(FakeCard, {
title: "Title",
href: "/detail",
});
expect(getByRole("link", { name: "Read more" })).toHaveAttribute(
"href",
"/detail",
);
});
it("shows tags in an accessible list", async () => {
const { getByRole } = await renderAstro(FakeCard, {
title: "Title",
tags: ["tag1", "tag2"],
});
const list = getByRole("list", { name: "Tags" });
expect(within(list).getByText("tag1")).toBeInTheDocument();
expect(within(list).getByText("tag2")).toBeInTheDocument();
});
it("uses article as the semantic container", async () => {
const { getByRole } = await renderAstro(FakeCard, { title: "Title" });
expect(getByRole("article")).toBeInTheDocument();
});
});
Notice what we’re actually verifying:
getByRole("heading", { level: 2 })— nottoContain('<h2'). We verify that a level 2 heading exists, which implies the semantic HTML is correct.getByRole("link", { name: "Read more" })— nottoContain('href'). We verify the link has the correct accessible text, which is what a screen reader would announce.getByRole("list", { name: "Tags" })— we verify the list has anaria-labelthat identifies it for users of assistive technologies.
In Practice: Testing a Full Page
This is where the solution really shines. A FakePage.astro page with a full <html> structure:
---
import FakeCard from "./FakeCard.astro";
interface Props {
title: string;
cards: { title: string; description?: string }[];
}
const { title, cards } = Astro.props;
---
<html lang="en">
<head>
<title>{title}</title>
</head>
<body>
<main>
<h1>{title}</h1>
{cards.map((card) => (
<FakeCard title={card.title} description={card.description} />
))}
</main>
</body>
</html>
The test:
// FakePage.test.ts
import { describe, it, expect } from "vitest";
import { within } from "@testing-library/dom";
import { renderAstro } from "@/test/renderAstro";
import FakePage from "./FakePage.astro";
describe("FakePage", () => {
it("sets the document title in the <title> tag", async () => {
const { document } = await renderAstro(FakePage, {
title: "Test title",
cards: [],
});
expect(document.title).toBe("Test title");
});
it("contains a main region", async () => {
const { getByRole } = await renderAstro(FakePage, {
title: "Title",
cards: [],
});
expect(getByRole("main")).toBeInTheDocument();
});
it("renders cards inside main", async () => {
const { getByRole } = await renderAstro(FakePage, {
title: "Page",
cards: [{ title: "Card 1", description: "Desc 1" }, { title: "Card 2" }],
});
const main = getByRole("main");
expect(
within(main).getByRole("heading", { name: "Card 1" }),
).toBeInTheDocument();
expect(within(main).getByText("Desc 1")).toBeInTheDocument();
});
it("renders as many articles as cards passed", async () => {
const { getAllByRole } = await renderAstro(FakePage, {
title: "Page",
cards: [{ title: "A" }, { title: "B" }, { title: "C" }],
});
expect(getAllByRole("article")).toHaveLength(3);
});
});
document.title gives us access to the <title> in <head>, which was impossible with the approach of injecting into document.body.
Minimal Required Setup
For all of this to work you only need three dependencies (you probably already have them if you use Vitest):
npm install -D @testing-library/dom @testing-library/jest-dom jsdom
And a vitest.setup.ts with a single line:
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
This enables jest-dom matchers (toBeInTheDocument, toHaveAttribute, toHaveTextContent, etc.) across all your tests. That’s it.
Astro tests using renderAstro run in node environment (Vitest’s default), so you don’t need any @vitest-environment jsdom directive or special environment configuration.
Before and After
Before — string-based test
it("renders the title", async () => {
const html = await renderAstroComponent(FakeCard, {
props: { title: "Title" },
});
expect(html).toContain("Title");
expect(html).toMatch(/<h2[^>]*>.*Title.*<\/h2>/s); // fragile regex
expect(html).toContain("<article"); // doesn't verify semantics
});
After — semantic test
it("renders the title in a level 2 heading", async () => {
const { getByRole } = await renderAstro(FakeCard, { title: "Title" });
expect(
getByRole("heading", { level: 2, name: "Title" }),
).toBeInTheDocument();
expect(getByRole("article")).toBeInTheDocument(); // verifies ARIA role
});
The second version:
- Is more readable.
- Fails with descriptive error messages.
- Verifies semantics and accessibility, not syntax.
- Doesn’t break if you change the internal structure as long as the semantic output remains the same.
Current Limitations
It’s only fair to mention what this pattern does not cover yet:
- Interactivity:
renderAstrorenders Astro’s static HTML. Island components (React, Vue, Svelte) are rendered without hydration. To test interactions in islands, use the tools from each framework directly (@testing-library/react, etc.). - Data fetching: if your component calls
getCollection()or other data layer functions, you’ll need to mock those dependencies. That’s independent of this pattern. - Evolving API:
experimental_AstroContaineris, as the name suggests, experimental. The API may change in future versions of Astro.
A Proposal for the Community
Astro’s Container API is a powerful tool that’s still underused. The entry barrier — the conflict with jsdom — discourages many from even trying Testing Library with .astro components.
The renderAstro pattern removes that barrier with very little code. Here’s what I propose as a starting point for any project:
// src/test/renderAstro.ts — copy and adapt it
import { experimental_AstroContainer as Container } from "astro/container";
import { type ContainerRenderOptions } from "astro/container";
import { JSDOM } from "jsdom";
import { getQueriesForElement, within } from "@testing-library/dom";
type AstroComponent = Parameters<Container["renderToString"]>[0];
export async function renderAstro(
Component: AstroComponent,
props: Record<string, unknown> = {},
options: ContainerRenderOptions = {},
) {
const astroContainer = await Container.create();
const plainHtml = await astroContainer.renderToString(Component, {
props,
...options,
});
const dom = new JSDOM(plainHtml);
const { document } = dom.window;
const queries = getQueriesForElement(document.body);
return { container: document.body, document, within, plainHtml, ...queries };
}
If you use it, improve it, or find cases where it breaks — share it. The Astro community is active and utilities like this only get better with more eyes on them.
Conclusion
Testing Library’s “test the way the user uses it” philosophy applies to Astro. You just needed to find the path around the conflict between esbuild and global jsdom. The key — instantiating JSDOM locally rather than assuming a global environment — is elegant, minimal, and side-effect free.
It’s not a workaround. It’s the same pattern React Testing Library has used from day one. It just hadn’t been applied to .astro yet.
Tested with: Astro 5.16.4 · Vitest 4.0.9 · @testing-library/dom 10.4.1 · jsdom (included as a peer dep of Vitest)