Type something to search...
How to test Astro components with Vitest?

How to test Astro components with Vitest?

To test Astro components with Vitest, create a vitest.config.ts that wraps your settings in Astro's getViteConfig() helper, then use the Container API to render components in your tests. experimental_AstroContainer.create() gives you a container, and container.renderToString(Component, { props, slots }) returns the component's HTML, which you can check with normal assertions such as expect(html).toContain(...). Run the tests in Vitest's node environment, because since Astro 6 Astro components can't be rendered in browser-like environments such as jsdom. For interactive framework components, test them separately with a library like Testing Library in a jsdom environment.

Astro components render on the server and produce HTML, so testing them is mostly about checking that output for different props, slots and requests. In this article you'll learn how to set up Vitest in an Astro project, how to test props, default and named slots, components that read Astro.url, API endpoints, components that render React islands, and the React components themselves. You'll also see the errors to expect when something is misconfigured and how to keep TypeScript happy.

What You Can Test

An Astro project usually has three kinds of code worth testing, and each suits a slightly different approach:

CodeToolEnvironment
Plain TypeScript utilitiesVitestnode
.astro components and endpointsVitest with the Container APInode
React, Vue, Svelte or other framework componentsVitest with Testing Libraryjsdom or happy-dom

For checking a full page in a real browser, with navigation, hydration and styles, add an end-to-end tool such as Playwright on top. Unit tests are faster and catch most mistakes earlier, so they're the focus here.

The Container API is still marked experimental, which is why it's imported as experimental_AstroContainer. It's the officially documented way to test components and it works well, but its options can change in minor releases, so check the changelog when you upgrade.

Step 1: Install and Configure Vitest

Install Vitest as a development dependency:

npm install -D vitest

Create a config file that uses getViteConfig(). This loads your Astro config, including integrations and Vite settings, so that .astro files and Astro's virtual modules work inside tests:

// vitest.config.ts
/// <reference types="vitest/config" />
import { getViteConfig } from 'astro/config';

export default getViteConfig({
  test: {
    environment: 'node',
    include: ['tests/**/*.test.{js,ts,tsx}'],
  },
});

If your tests need different Astro settings from the real site, pass them as a second argument, for example getViteConfig({ test: {} }, { site: 'https://example.com/' }).

Add a script to package.json:

{
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "test": "vitest"
  }
}

npm test runs Vitest in watch mode during development, and npx vitest run runs the suite once, which is what you want in CI. The examples below were tested with Astro 7.4.2 and Vitest 5.0.5. Since Astro 6, getViteConfig() needs at least Vitest 3.2, so upgrade old setups first.

Step 2: Test a Plain Utility

Start with the simplest case, a function with no Astro involved. A reading-time helper is a good example:

// src/lib/reading-time.ts
export function readingTime(text: string, wordsPerMinute = 200): number {
  const words = text.trim().split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(words / wordsPerMinute));
}
// tests/reading-time.test.ts
import { describe, expect, it } from 'vitest';
import { readingTime } from '../src/lib/reading-time';

describe('readingTime', () => {
  it('rounds up to the next minute', () => {
    const text = 'word '.repeat(450);
    expect(readingTime(text)).toBe(3);
  });

  it('never returns less than one minute', () => {
    expect(readingTime('')).toBe(1);
  });
});

Keeping logic like this in src/lib/ rather than inside component frontmatter makes it much easier to test. The post on adding reading time to Astro posts uses a helper very much like this one.

Step 3: Test an Astro Component with Props and Slots

Here's a card component with a prop, a default slot and a named slot with fallback content:

---
// src/components/Card.astro
interface Props {
  title: string;
  href?: string;
}
const { title, href } = Astro.props;
---
<article class="card">
  <h2>{href ? <a href={href}>{title}</a> : title}</h2>
  <div class="body"><slot /></div>
  <footer><slot name="footer">No footer</slot></footer>
</article>

The test creates a container once and renders the component with different inputs:

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

describe('Card', () => {
  let container: AstroContainer;

  beforeAll(async () => {
    container = await AstroContainer.create();
  });

  it('renders the title and default slot', async () => {
    const html = await container.renderToString(Card, {
      props: { title: 'Hello' },
      slots: { default: '<p>Card body</p>' },
    });

    expect(html).toContain('<h2>Hello</h2>');
    expect(html).toContain('<p>Card body</p>');
  });

  it('links the title when href is passed', async () => {
    const html = await container.renderToString(Card, {
      props: { title: 'Docs', href: '/docs/' },
    });

    expect(html).toContain('<a href="/docs/">Docs</a>');
  });

  it('uses slot fallback content and named slots', async () => {
    const withoutFooter = await container.renderToString(Card, {
      props: { title: 'A' },
    });
    const withFooter = await container.renderToString(Card, {
      props: { title: 'B' },
      slots: { footer: 'Read more' },
    });

    expect(withoutFooter).toContain('No footer');
    expect(withFooter).toContain('<footer>Read more</footer>');
  });
});

The rendering options map directly to how you'd use the component:

  • props: the values you'd pass as attributes, available as Astro.props.
  • slots: an object whose keys are slot names. Use default for the unnamed slot. Values are HTML strings, and you can render another component first with renderToString() and pass its output as a slot.

The guides to component props and slots cover how these work in the components themselves.

Prefer Specific Assertions

toContain() on an HTML string is simple and works well for small components, but it's sensitive to formatting. In my tests the container's output was compact, with no whitespace between elements: rendering the card with a title of T and a body of b returned <article class="card"><h2>T</h2><div class="body">b</div><footer>No footer</footer></article>. That makes assertions like '<footer>Read more</footer>' predictable, and it's consistent with Astro 7's default compressHTML: 'jsx' whitespace handling. Notice also that the scoped style hash appears as a data-astro-cid-* attribute on elements of components that have styles. For larger components, consider parsing the string with a DOM library and querying it, so your tests check structure rather than exact text.

Step 4: Test Components That Read the Request

Components that use Astro.url, Astro.request or cookies need a request. Pass one with the request option. This navigation link marks itself as the current page:

---
// src/components/NavLink.astro
interface Props {
  href: string;
}
const { href } = Astro.props;
const isActive = Astro.url.pathname === href;
---
<a href={href} aria-current={isActive ? 'page' : undefined}><slot /></a>
// tests/NavLink.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import NavLink from '../src/components/NavLink.astro';

test('marks the current page link', async () => {
  const container = await AstroContainer.create();
  const render = (path: string) =>
    container.renderToString(NavLink, {
      props: { href: '/blog/' },
      slots: { default: 'Blog' },
      request: new Request(`https://example.com${path}`),
    });

  expect(await render('/blog/')).toContain('aria-current="page"');
  expect(await render('/about/')).not.toContain('aria-current');
});

The same render options also accept params for dynamic route pages that read Astro.params, and locals for components that depend on values set by middleware, such as the current user.

Step 5: Test API Endpoints

The container can also call endpoints. Import the endpoint module and pass routeType: 'endpoint' to renderToResponse(), which returns a standard Response:

// src/pages/api/hello.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = ({ url }) => {
  const name = url.searchParams.get('name') ?? 'world';
  return Response.json({ message: `Hello, ${name}!` });
};

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json();
  if (!body.email) {
    return Response.json({ error: 'Email is required' }, { status: 400 });
  }
  return Response.json({ ok: true }, { status: 201 });
};
// tests/hello-endpoint.test.js
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import * as endpoint from '../src/pages/api/hello';

test('GET greets by name', async () => {
  const container = await AstroContainer.create();
  const response = await container.renderToResponse(endpoint, {
    routeType: 'endpoint',
    request: new Request('https://example.com/api/hello?name=Ada'),
  });

  expect(response.status).toBe(200);
  expect(await response.json()).toEqual({ message: 'Hello, Ada!' });
});

test('POST validates the body', async () => {
  const container = await AstroContainer.create();
  const response = await container.renderToResponse(endpoint, {
    routeType: 'endpoint',
    request: new Request('https://example.com/api/hello', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    }),
  });

  expect(response.status).toBe(400);
  expect(await response.json()).toEqual({ error: 'Email is required' });
});

The request's method decides which exported handler runs. This test file is plain JavaScript on purpose: in a .ts file, astro check reports that the endpoint module isn't assignable to AstroComponentFactory, the type renderToResponse() declares, even though the call works at runtime. Writing endpoint tests in .js sidesteps that without casts. For more on endpoints themselves, see Astro API endpoints.

Step 6: Test Components That Render Framework Islands

If an Astro component renders a React, Vue, Svelte, Solid or Preact component, the container needs that framework's renderer. Load it with loadRenderers() from astro:container and the integration's getContainerRenderer():

// src/components/Counter.tsx
import { useState } from 'react';

export default function Counter({ start = 0 }: { start?: number }) {
  const [count, setCount] = useState(start);
  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}
---
// src/components/CounterIsland.astro
import Counter from './Counter';
---
<section>
  <h2>Try it</h2>
  <Counter client:visible start={5} />
</section>
// tests/CounterIsland.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { getContainerRenderer } from '@astrojs/react/container-renderer';
import { loadRenderers } from 'astro:container';
import { expect, test } from 'vitest';
import CounterIsland from '../src/components/CounterIsland.astro';

test('server-renders the React island', async () => {
  const renderers = await loadRenderers([getContainerRenderer()]);
  const container = await AstroContainer.create({ renderers });
  const html = await container.renderToString(CounterIsland);

  expect(html).toContain('<astro-island');
  expect(html).toContain('Count: <!-- -->5');
});

Note the import path: since Astro 7, getContainerRenderer() comes from a dedicated container-renderer entry point, such as @astrojs/react/container-renderer. Importing it from the package root is deprecated. The same entry point exists for @astrojs/preact, @astrojs/solid-js, @astrojs/svelte, @astrojs/vue and @astrojs/mdx.

The rendered output contains an <astro-island> wrapper with the server-rendered button inside. The <!-- --> comment is React's marker between adjacent text nodes, which is a good reason to keep island assertions loose.

Step 7: Test Framework Components in jsdom

The container tests the server render. To test interactivity, such as clicking the counter, test the React component directly with Testing Library in a browser-like environment:

npm install -D jsdom @testing-library/react @testing-library/dom

A comment at the top of the file switches just that file to jsdom, leaving the rest of the suite in node:

// @vitest-environment jsdom
// tests/Counter.test.tsx
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import Counter from '../src/components/Counter';

afterEach(cleanup);

test('increments when clicked', () => {
  render(<Counter start={2} />);
  const button = screen.getByRole('button');

  fireEvent.click(button);

  expect(button.textContent).toBe('Count: 3');
});

This split is required, not just tidy. Since Astro 6, Astro components can't be rendered in client environments such as jsdom or happy-dom. When I tried rendering Card.astro in a jsdom test file, it failed with:

NoMatchingRenderer: Unable to render ``.
No valid renderer was found for this file extension.

If you see that error, check the environment of the test file. If your whole Vitest config used environment: 'jsdom' before upgrading, change it to node and opt individual framework test files into jsdom with the comment above.

Rendering Styles and Scripts with renderComponent()

renderToString() returns the component's markup without its styles and scripts. Since Astro 7.3.5, the container also has renderComponent(), which includes inlined <style> and <script> output. It needs the component imported with a ?container query:

---
// src/components/Badge.astro
const { label } = Astro.props;
---
<span class="badge">{label}</span>
<style>
  .badge { color: rebeccapurple; }
</style>
// tests/Badge.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Badge from '../src/components/Badge.astro?container';

test('includes scoped styles', async () => {
  const container = await AstroContainer.create();
  const html = await container.renderComponent(Badge, { props: { label: 'New' } });

  expect(html).toContain('<style>');
  expect(html).toContain('New');
});

Only inlined styles and scripts are included. Assets pulled in through imports, such as @import in CSS, aren't. TypeScript doesn't know about the ?container suffix, so astro check reports "Cannot find module" until you declare it:

// src/container.d.ts
declare module '*.astro?container' {
  import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
  const component: AstroComponentFactory;
  export default component;
}

Running the Suite

With all the files above in place, npx vitest run --reporter=verbose reported:

 ✓ tests/reading-time.test.ts > readingTime > rounds up to the next minute 1ms
 ✓ tests/reading-time.test.ts > readingTime > never returns less than one minute 0ms
 ✓ tests/Card.test.ts > Card > renders the title and default slot 22ms
 ✓ tests/Card.test.ts > Card > links the title when href is passed 2ms
 ✓ tests/Card.test.ts > Card > uses slot fallback content and named slots 1ms
 ✓ tests/NavLink.test.ts > marks the current page link 29ms
 ✓ tests/hello-endpoint.test.ts > GET greets by name 29ms
 ✓ tests/hello-endpoint.test.ts > POST validates the body 4ms
 ✓ tests/CounterIsland.test.ts > server-renders the React island 34ms
 ✓ tests/Counter.test.tsx > increments when clicked 64ms
 Test Files  6 passed (6)
      Tests  10 passed (10)

That run was before the endpoint file was renamed to .js and the Badge test was added; after both changes the suite reported 7 files and 11 tests passing, and astro check reported no errors.

Running Tests in CI

In a CI pipeline, run npx vitest run before your build. If your components import from astro:content, run npx astro sync first so the collection types and data exist. And if your config reads environment variables through astro:env, provide test values in the CI environment, as secrets are validated when the server module is imported.


FAQ: Testing Astro Components with Vitest

No, it is still experimental and is imported as experimental_AstroContainer. It is the documented way to test Astro components and works reliably, but its API can change in minor releases, so read the changelog when you upgrade Astro.

Since Astro 6, Astro components can only be rendered in a server environment. Set the Vitest environment to node for tests that render Astro components, and opt only your framework component tests into jsdom with a vitest-environment comment.

Yes, for anything that imports .astro files or Astro virtual modules. getViteConfig loads your Astro config and integrations so that Vitest can compile components the same way Astro does. Plain TypeScript utilities would work without it, but using it everywhere keeps one config.

Load the React renderer with loadRenderers from astro:container and getContainerRenderer from @astrojs/react/container-renderer, then pass the result to AstroContainer.create. The server-rendered island HTML will then appear in the output.

Yes. A page is an Astro component, so you can render it with the container and pass params for dynamic routes. For full pages that depend on layouts, collections and middleware, an end-to-end test with Playwright against a built site is often more reliable.

Import the endpoint module, call renderToResponse with routeType set to endpoint, and pass a Request with the method, headers and body you want to test. The result is a standard Response you can inspect with status and json.


Conclusion

Testing Astro components with Vitest takes very little setup: a vitest.config.ts built with getViteConfig(), the node environment, and the Container API. With renderToString() you can check how a component responds to props, slots and requests, renderToResponse() covers endpoints, and loading framework renderers lets you verify that islands render on the server.

Keep interactive framework components in their own test files under jsdom with Testing Library, since Astro components no longer render there. Put shared logic in plain modules so it's easy to unit test, use the container-renderer entry points introduced in Astro 7, and add a type declaration if you use renderComponent() with ?container imports. Together that gives you fast tests that catch broken markup long before it reaches the browser.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper