Astro Testing: Vitest, Components & Playwright E2E (2026)

Astro Testing: Vitest, Components & Playwright E2E (2026)

Astro's build output is mostly static HTML. That makes it fast and SEO-friendly — and it also makes testing feel optional right up until something breaks in production. A misconfigured endpoint returns 500s, an interactive island stops hydrating, or a content collection query returns an empty array after a schema change. None of these show up until users hit them.

This is the testing setup that works for Astro in 2026: Vitest for units and components, Playwright for E2E, and production monitoring for what tests can't catch.

Vitest Setup for Astro

Astro uses Vite internally, so Vitest is the natural choice for unit and component tests. Install the dependencies:

npm install -D vitest @testing-library/jest-dom jsdom

Create vitest.config.ts at the project root:

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

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    exclude: ['**/node_modules/**', '**/e2e/**'],
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

Add scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Vitest picks up *.test.ts and *.spec.ts files anywhere in your project. Run npm test to verify the setup works before writing your first test.

Testing Astro Components

Astro components (.astro files) compile to static HTML with no client-side JS by default. Testing them directly requires the Astro container API, which lets you render .astro components in a Node.js environment without a browser.

Install the container API package:

npm install -D @astrojs/test-utils
<!-- src/components/PostCard.astro -->
---
interface Props {
  title: string;
  excerpt: string;
  slug: string;
  publishedAt: string;
}

const { title, excerpt, slug, publishedAt } = Astro.props;
---

<article class="post-card">
  <h2><a href={`/blog/${slug}`}>{title}</a></h2>
  <p class="excerpt">{excerpt}</p>
  <time datetime={publishedAt}>{new Date(publishedAt).toLocaleDateString()}</time>
</article>
// src/components/PostCard.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect } from 'vitest';
import PostCard from './PostCard.astro';

describe('PostCard', () => {
  it('renders the post title with a link to the correct slug', async () => {
    const container = await AstroContainer.create();
    const result = await container.renderToString(PostCard, {
      props: {
        title: 'Getting Started with Astro',
        excerpt: 'A beginner's guide to the Astro framework.',
        slug: 'getting-started-astro',
        publishedAt: '2026-01-15',
      },
    });

    expect(result).toContain('Getting Started with Astro');
    expect(result).toContain('href="/blog/getting-started-astro"');
  });

  it('renders the excerpt text', async () => {
    const container = await AstroContainer.create();
    const result = await container.renderToString(PostCard, {
      props: {
        title: 'Test Post',
        excerpt: 'This is the excerpt text.',
        slug: 'test-post',
        publishedAt: '2026-01-15',
      },
    });

    expect(result).toContain('This is the excerpt text.');
  });

  it('renders the date in a <time> element with the correct datetime attribute', async () => {
    const container = await AstroContainer.create();
    const result = await container.renderToString(PostCard, {
      props: {
        title: 'Test',
        excerpt: 'Test excerpt',
        slug: 'test',
        publishedAt: '2026-03-20',
      },
    });

    expect(result).toContain('datetime="2026-03-20"');
  });
});

For components that don't use Astro-specific features (props injection, slots, content collections), you can test their logic separately as pure TypeScript functions and skip the container entirely.

Testing Astro Endpoints (Server Routes)

Astro endpoints (src/pages/api/*.ts) are plain async functions that return a Response. Test them directly — no server required.

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

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json();

  if (!body.email || !body.email.includes('@')) {
    return new Response(JSON.stringify({ error: 'Valid email required' }), {
      status: 422,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  if (!body.message || body.message.trim().length < 10) {
    return new Response(JSON.stringify({ error: 'Message must be at least 10 characters' }), {
      status: 422,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  // Send email...
  return new Response(JSON.stringify({ success: true }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};
// src/pages/api/contact.test.ts
import { describe, it, expect } from 'vitest';
import { POST } from './contact';

function makeRequest(body: object) {
  return new Request('http://localhost/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
}

describe('POST /api/contact', () => {
  it('returns 422 when email is missing', async () => {
    const response = await POST({ request: makeRequest({ message: 'Long enough message here' }) } as any);
    expect(response.status).toBe(422);
    const data = await response.json();
    expect(data.error).toMatch(/email/i);
  });

  it('returns 422 when email is invalid', async () => {
    const response = await POST({
      request: makeRequest({ email: 'notanemail', message: 'Long enough message here' }),
    } as any);
    expect(response.status).toBe(422);
  });

  it('returns 422 when message is too short', async () => {
    const response = await POST({
      request: makeRequest({ email: 'user@example.com', message: 'Short' }),
    } as any);
    expect(response.status).toBe(422);
    const data = await response.json();
    expect(data.error).toMatch(/10 characters/i);
  });

  it('returns 200 with success for valid input', async () => {
    const response = await POST({
      request: makeRequest({
        email: 'user@example.com',
        message: 'This is a valid message that is long enough.',
      }),
    } as any);
    expect(response.status).toBe(200);
    const data = await response.json();
    expect(data.success).toBe(true);
  });
});

These tests run in milliseconds and cover every validation branch. Write them before implementing the validation logic — they fail first, then you make them pass.

Testing SSR vs Static Output

Astro supports output: 'static', output: 'server', and output: 'hybrid'. The testing implications are different for each.

Static output — pages are pre-rendered at build time. Test the build output directly:

// src/utils/formatDate.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, slugify } from './utils';

describe('formatDate', () => {
  it('formats ISO date string to human-readable format', () => {
    expect(formatDate('2026-03-20')).toBe('March 20, 2026');
  });

  it('handles single-digit months and days', () => {
    expect(formatDate('2026-01-05')).toBe('January 5, 2026');
  });
});

describe('slugify', () => {
  it('converts spaces to hyphens and lowercases', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('strips special characters', () => {
    expect(slugify('React & TypeScript: A Guide')).toBe('react-typescript-a-guide');
  });
});

SSR output — test endpoints and middleware as functions, as shown above. For full SSR route rendering, use Playwright against the running dev server.

Content collections — test the schema and data retrieval:

// src/content/posts.test.ts
import { describe, it, expect } from 'vitest';
import { z } from 'astro:content';

// Test schema validation directly
const postSchema = z.object({
  title: z.string().min(1),
  publishedAt: z.date(),
  tags: z.array(z.string()).default([]),
  draft: z.boolean().default(false),
});

describe('post content schema', () => {
  it('accepts valid post frontmatter', () => {
    const result = postSchema.safeParse({
      title: 'My Post',
      publishedAt: new Date('2026-01-01'),
      tags: ['testing'],
      draft: false,
    });
    expect(result.success).toBe(true);
  });

  it('rejects an empty title', () => {
    const result = postSchema.safeParse({
      title: '',
      publishedAt: new Date(),
    });
    expect(result.success).toBe(false);
  });

  it('applies default values for optional fields', () => {
    const result = postSchema.safeParse({
      title: 'Post',
      publishedAt: new Date(),
    });
    expect(result.success).toBe(true);
    expect(result.data?.tags).toEqual([]);
    expect(result.data?.draft).toBe(false);
  });
});

Island Architecture Testing Considerations

Astro's island architecture means interactive components (React, Vue, Svelte, Solid) are selectively hydrated with client:* directives. This creates a unique testing challenge: the component itself might be correct, but the hydration directive might be wrong.

Test the framework component in isolation using its own testing tools:

// src/components/SearchBar.test.tsx  (React island)
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import SearchBar from './SearchBar';

describe('SearchBar', () => {
  it('calls onSearch with the input value when form is submitted', async () => {
    const onSearch = vi.fn();
    render(<SearchBar onSearch={onSearch} />);

    fireEvent.change(screen.getByRole('searchbox'), {
      target: { value: 'astro testing' },
    });
    fireEvent.submit(screen.getByRole('form'));

    expect(onSearch).toHaveBeenCalledWith('astro testing');
  });

  it('clears the input after submission', async () => {
    const onSearch = vi.fn();
    render(<SearchBar onSearch={onSearch} />);

    const input = screen.getByRole('searchbox');
    fireEvent.change(input, { target: { value: 'test query' } });
    fireEvent.submit(screen.getByRole('form'));

    expect(input).toHaveValue('');
  });
});

The hydration directive itself (client:load, client:idle, client:visible) is tested in Playwright E2E — you verify the component actually becomes interactive in the browser, not just that it renders correct HTML.

Playwright E2E for Astro

Playwright tests the full rendered site in a real browser. It's the only way to verify that hydration works, that client:visible islands activate when scrolled into view, and that multi-page navigation works correctly.

npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run build && npm run preview',
    port: 4321,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4321',
  },
});
// e2e/blog.test.ts
import { test, expect } from '@playwright/test';

test.describe('blog listing', () => {
  test('shows a list of posts on the blog index', async ({ page }) => {
    await page.goto('/blog');
    const posts = page.locator('article.post-card');
    await expect(posts).toHaveCount({ min: 1 });
  });

  test('navigates to a post when the title link is clicked', async ({ page }) => {
    await page.goto('/blog');
    const firstPostLink = page.locator('article.post-card a').first();
    const postTitle = await firstPostLink.textContent();
    await firstPostLink.click();
    await expect(page).not.toHaveURL('/blog');
    await expect(page.locator('h1')).toHaveText(postTitle!);
  });
});

test.describe('search island', () => {
  test('search island is interactive after load', async ({ page }) => {
    await page.goto('/blog');
    // If search uses client:load, it should be interactive immediately
    const searchInput = page.getByRole('searchbox');
    await expect(searchInput).toBeVisible();
    await searchInput.fill('astro');
    // Verify the island responded — results filter or search fires
    await expect(page.locator('[data-testid="search-results"]')).toBeVisible();
  });
});

Keep E2E tests focused on user flows and island behavior — things that can only be verified in a real browser. Validation logic, data transformation, and component rendering all belong in Vitest.

What Your Tests Still Miss

Vitest and Playwright tests run against a build you control in an environment you control. They don't catch:

  • A content CDN returning stale or corrupted assets after a cache invalidation
  • A third-party script (analytics, chat widget) blocking page interactivity
  • Build output that works locally but fails in the hosting environment (Vercel, Netlify, Cloudflare Pages) due to edge runtime differences
  • A content collection query that returns zero results after a schema migration on a live site
  • Lighthouse regressions — a dependency update balloons your JS bundle and Core Web Vitals scores drop
  • An API endpoint returning 200 OK but with a response body the frontend can't parse

These are production failures. Your tests won't see them. Monitoring will.

Monitoring Astro Sites in Production with HelpMeTest

Install HelpMeTest:

curl -fsSL https://helpmetest.com/install | bash

Write tests in plain English that run against your live site on a schedule:

Go to https://yourastrosite.com/blog
Verify at least one article is visible on the page
Click the first article link
Verify the article title is visible as an h1
Verify the article content is not empty
Verify the page has no console errors
Go to https://yourastrosite.com/contact
Fill in the email field with "test@example.com"
Fill in the message field with "This is a test message for monitoring."
Click the submit button
Verify the thank-you message is visible

HelpMeTest runs these every few minutes and alerts you when they fail. If your Astro site breaks in production — an endpoint starts returning 500s, a hydrated island stops working, a content collection goes empty — you know in minutes, not when a user files a support ticket.

Free tier: 10 tests, unlimited health checks. Pro: $100/month for full coverage across all your routes and islands.

Your Vitest tests prove the code is correct. Playwright proves the build works. HelpMeTest proves the deployed site is working right now.


Start at helpmetest.com — free tier, no credit card.

Read more