Astro Component Testing with Vitest

Astro Component Testing with Vitest

Astro's component model is unique — .astro files are compiled at build time, which means standard component testing approaches from React or Vue don't apply directly. But with Vitest and Astro's experimental container API, you can write fast, reliable unit tests for your Astro components without spinning up a full dev server. This guide walks through everything from initial setup to advanced patterns like testing data fetching and slot content.

Why Test Astro Components in Isolation?

Before diving into setup, it's worth understanding what you're actually testing. Astro components are essentially HTML templates with a JavaScript frontmatter section that runs at build/request time. They produce static HTML — there's no virtual DOM, no reactive state (unless you're using an island). This makes them excellent candidates for snapshot-style and output-validation tests.

Testing in isolation catches:

  • Incorrect HTML structure from broken prop logic
  • Missing conditional renders
  • Slot content being ignored or placed wrong
  • Data fetching errors that silently return empty content
  • SEO-critical attributes missing from rendered output

Setting Up Vitest for Astro

Start by installing the required dependencies:

npm install -D vitest @astrojs/check
npm install -D vite

For the experimental container API (which lets you render Astro components in tests), you need Astro 4.9 or later:

npm install astro@latest

Create a vitest.config.ts at your project root:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
    exclude: ['node_modules', 'dist', '.astro'],
  },
});

For component rendering tests specifically, you'll use Astro's container API which runs in a Node environment — no browser required.

Using the Astro Container API

The container API (@astrojs/container) lets you render Astro components to HTML strings in your tests. As of Astro 4.9, this is available as an experimental feature:

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

test('Card renders title and description', async () => {
  const container = await AstroContainer.create();
  const result = await container.renderToString(Card, {
    props: {
      title: 'Test Card',
      description: 'A test description',
    },
  });

  expect(result).toContain('Test Card');
  expect(result).toContain('A test description');
});

Your Card.astro component might look like:

---
// src/components/Card.astro
interface Props {
  title: string;
  description: string;
  href?: string;
  featured?: boolean;
}

const { title, description, href, featured = false } = Astro.props;
---

<div class:list={['card', { 'card--featured': featured }]}>
  <h2 class="card__title">
    {href ? <a href={href}>{title}</a> : title}
  </h2>
  <p class="card__description">{description}</p>
</div>

Testing Component Props

Props testing is where you validate that your component handles all input combinations correctly — required fields, optional fields with defaults, and edge cases.

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

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

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

  test('renders required props correctly', async () => {
    const result = await container.renderToString(Card, {
      props: { title: 'Hello', description: 'World' },
    });

    expect(result).toContain('<h2');
    expect(result).toContain('Hello');
    expect(result).toContain('World');
  });

  test('renders title as link when href is provided', async () => {
    const result = await container.renderToString(Card, {
      props: {
        title: 'Linked Card',
        description: 'Desc',
        href: '/some-page',
      },
    });

    expect(result).toContain('<a href="/some-page">');
    expect(result).toContain('Linked Card');
  });

  test('renders title as plain text when href is absent', async () => {
    const result = await container.renderToString(Card, {
      props: { title: 'Plain Card', description: 'Desc' },
    });

    expect(result).not.toContain('<a href=');
    expect(result).toContain('Plain Card');
  });

  test('applies featured class when featured prop is true', async () => {
    const result = await container.renderToString(Card, {
      props: {
        title: 'Featured',
        description: 'Featured desc',
        featured: true,
      },
    });

    expect(result).toContain('card--featured');
  });

  test('does not apply featured class by default', async () => {
    const result = await container.renderToString(Card, {
      props: { title: 'Normal', description: 'Normal desc' },
    });

    expect(result).not.toContain('card--featured');
  });
});

Testing Slots

Slots in Astro let parent components inject content into named or default positions. Testing slot rendering ensures your layout components don't silently drop injected content.

---
// src/components/Panel.astro
interface Props {
  variant?: 'info' | 'warning' | 'error';
}

const { variant = 'info' } = Astro.props;
---

<div class={`panel panel--${variant}`}>
  <div class="panel__header">
    <slot name="header" />
  </div>
  <div class="panel__body">
    <slot />
  </div>
  {Astro.slots.has('footer') && (
    <div class="panel__footer">
      <slot name="footer" />
    </div>
  )}
</div>

Testing slots requires passing slot content via the container API's slots option:

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

describe('Panel component slots', () => {
  let container: AstroContainer;

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

  test('renders default slot content', async () => {
    const result = await container.renderToString(Panel, {
      slots: {
        default: 'Main body content here',
      },
    });

    expect(result).toContain('panel__body');
    expect(result).toContain('Main body content here');
  });

  test('renders named header slot', async () => {
    const result = await container.renderToString(Panel, {
      slots: {
        header: '<h3>Panel Title</h3>',
        default: 'Body',
      },
    });

    expect(result).toContain('panel__header');
    expect(result).toContain('<h3>Panel Title</h3>');
  });

  test('conditionally renders footer slot when provided', async () => {
    const result = await container.renderToString(Panel, {
      slots: {
        default: 'Body',
        footer: 'Footer content',
      },
    });

    expect(result).toContain('panel__footer');
    expect(result).toContain('Footer content');
  });

  test('omits footer section when footer slot is absent', async () => {
    const result = await container.renderToString(Panel, {
      slots: {
        default: 'Body only',
      },
    });

    expect(result).not.toContain('panel__footer');
  });
});

Testing Rendering Output and HTML Structure

For more precise HTML structure validation, parse the rendered output:

// src/components/__tests__/NavBar.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, expect, test, beforeEach } from 'vitest';
import { parse } from 'node-html-parser';
import NavBar from '../NavBar.astro';

describe('NavBar rendering output', () => {
  let container: AstroContainer;

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

  test('renders correct number of nav links', async () => {
    const result = await container.renderToString(NavBar, {
      props: {
        links: [
          { label: 'Home', href: '/' },
          { label: 'Blog', href: '/blog' },
          { label: 'About', href: '/about' },
        ],
      },
    });

    const root = parse(result);
    const links = root.querySelectorAll('nav a');
    expect(links).toHaveLength(3);
  });

  test('marks current page link as active', async () => {
    const result = await container.renderToString(NavBar, {
      props: {
        links: [
          { label: 'Home', href: '/' },
          { label: 'Blog', href: '/blog' },
        ],
        currentPath: '/blog',
      },
    });

    const root = parse(result);
    const activeLink = root.querySelector('a[aria-current="page"]');
    expect(activeLink).not.toBeNull();
    expect(activeLink?.text).toBe('Blog');
  });
});

Install node-html-parser for this:

npm install -D node-html-parser

Testing Data Fetching in Components

Astro components can fetch data in their frontmatter — a common pattern for content-heavy sites. Testing this requires mocking fetch or your data layer.

---
// src/components/PostList.astro
interface Post {
  id: string;
  title: string;
  slug: string;
  publishedAt: string;
}

const response = await fetch(`${import.meta.env.API_URL}/posts?limit=5`);
const posts: Post[] = await response.json();
---

<ul class="post-list">
  {posts.map(post => (
    <li class="post-list__item">
      <a href={`/blog/${post.slug}`}>{post.title}</a>
      <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString()}</time>
    </li>
  ))}
</ul>

{posts.length === 0 && (
  <p class="post-list__empty">No posts found.</p>
)}

Test with global fetch mocking via Vitest:

// src/components/__tests__/PostList.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, expect, test, beforeEach, vi, afterEach } from 'vitest';
import PostList from '../PostList.astro';

const mockPosts = [
  { id: '1', title: 'First Post', slug: 'first-post', publishedAt: '2024-01-15T00:00:00Z' },
  { id: '2', title: 'Second Post', slug: 'second-post', publishedAt: '2024-01-20T00:00:00Z' },
];

describe('PostList with data fetching', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
    // Set up environment variable
    vi.stubEnv('API_URL', 'https://api.example.com');
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllEnvs();
  });

  test('renders posts from API', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve(mockPosts),
      ok: true,
    } as Response);

    const result = await container.renderToString(PostList, {});

    expect(result).toContain('First Post');
    expect(result).toContain('second-post');
    expect(result).toContain('/blog/first-post');
  });

  test('renders empty state when API returns no posts', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve([]),
      ok: true,
    } as Response);

    const result = await container.renderToString(PostList, {});

    expect(result).toContain('No posts found.');
    expect(result).not.toContain('post-list__item');
  });

  test('calls API with correct URL', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      json: () => Promise.resolve(mockPosts),
      ok: true,
    } as Response);
    global.fetch = fetchMock;

    await container.renderToString(PostList, {});

    expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/posts?limit=5');
  });
});

Testing Content Collections

Astro's content collections are a first-class data layer. Test components that consume them by mocking the getCollection function:

// src/components/__tests__/BlogGrid.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, expect, test, vi, beforeEach } from 'vitest';

// Mock the astro:content module before importing the component
vi.mock('astro:content', () => ({
  getCollection: vi.fn(),
}));

import { getCollection } from 'astro:content';
import BlogGrid from '../BlogGrid.astro';

const mockEntries = [
  {
    id: 'post-1',
    slug: 'my-first-post',
    data: {
      title: 'My First Post',
      description: 'An intro post',
      pubDate: new Date('2024-01-01'),
      tags: ['astro', 'web'],
    },
  },
  {
    id: 'post-2',
    slug: 'second-post',
    data: {
      title: 'Second Post',
      description: 'More content',
      pubDate: new Date('2024-02-01'),
      tags: ['testing'],
    },
  },
];

describe('BlogGrid with content collections', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
    vi.mocked(getCollection).mockResolvedValue(mockEntries as any);
  });

  test('renders all collection entries', async () => {
    const result = await container.renderToString(BlogGrid, {});

    expect(result).toContain('My First Post');
    expect(result).toContain('Second Post');
  });

  test('renders correct slugs for links', async () => {
    const result = await container.renderToString(BlogGrid, {});

    expect(result).toContain('/blog/my-first-post');
    expect(result).toContain('/blog/second-post');
  });
});

Snapshot Testing for Stable Components

For components with well-defined, rarely-changing output, snapshot tests give you a safety net with minimal maintenance cost:

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

test('Footer renders consistently', async () => {
  const container = await AstroContainer.create();
  const result = await container.renderToString(Footer, {
    props: { year: 2024 },
  });

  expect(result).toMatchSnapshot();
});

Run with vitest --update-snapshots to regenerate snapshots after intentional changes.

Running Your Tests

Add scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

Run component tests:

npm test
# or for watch mode during development
npm run test:watch

Common Pitfalls

Environment variables not available: Astro uses import.meta.env — stub these with vi.stubEnv() in tests.

CSS modules and styles: The container API strips <style> blocks from output by default. If you're asserting on class names, target the HTML attributes directly.

Client-side JavaScript: The container API renders server-side HTML only. Client scripts aren't executed. For island behavior, write separate tests using your island's framework testing tools (e.g., React Testing Library for React islands).

Import aliases: Configure Vitest to resolve Astro's path aliases by adding your tsconfig.json paths to vitest.config.ts using vite-tsconfig-paths:

npm install -D vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    globals: true,
    environment: 'node',
  },
});

What to Test and What to Skip

Test these in unit tests:

  • Conditional rendering based on props
  • Slot content placement
  • Link generation logic
  • Data transformation in frontmatter
  • Error/empty states from data fetching

Skip in unit tests (cover in E2E instead):

  • CSS visual appearance
  • Client-side interactivity
  • Cross-component navigation
  • Hydration behavior

The Astro container API gives you a fast feedback loop for structural correctness. Pair it with Playwright E2E tests for behavior validation, and you have a complete testing strategy for any Astro project.

Read more

Start now free