Testing Astro Islands and Partial Hydration

Testing Astro Islands and Partial Hydration

Astro's island architecture is one of its defining features — you get static HTML for most of your page, with interactive components (islands) hydrated only where needed. This composable model is excellent for performance, but it introduces unique testing challenges: when does hydration happen? How do you test client:load vs client:visible behavior? How do you verify that a React island embedded in an Astro page actually works end-to-end?

This guide covers the full island testing spectrum: isolated unit tests for each island component, integration tests for hydration timing, and testing communication between islands.

Understanding What to Test

Before writing tests, understand the layers involved:

  1. The island component itself (React/Vue/Svelte code) — testable with framework-native tools
  2. The Astro wrapper (how the island is embedded and what props it receives) — testable with the container API
  3. Hydration behavior (does client:load hydrate immediately? does client:visible wait?) — requires Playwright
  4. Island-to-island communication (shared state, events) — integration tests with Playwright

Each layer gets different tests. Don't try to test everything in one place.

Testing React Islands in Isolation

React islands are just React components. Test them with React Testing Library — no Astro involvement needed.

// src/components/islands/SearchBar.tsx
import { useState, useRef } from 'react';

interface SearchBarProps {
  placeholder?: string;
  onSearch: (query: string) => void;
  initialQuery?: string;
}

export function SearchBar({ placeholder = 'Search...', onSearch, initialQuery = '' }: SearchBarProps) {
  const [query, setQuery] = useState(initialQuery);
  const [isLoading, setIsLoading] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!query.trim()) return;

    setIsLoading(true);
    try {
      await onSearch(query.trim());
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} role="search">
      <input
        ref={inputRef}
        type="search"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder={placeholder}
        aria-label="Search query"
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading || !query.trim()}>
        {isLoading ? 'Searching...' : 'Search'}
      </button>
    </form>
  );
}

Install React Testing Library:

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// src/components/islands/__tests__/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { SearchBar } from '../SearchBar';

describe('SearchBar island', () => {
  test('renders with default placeholder', () => {
    render(<SearchBar onSearch={vi.fn()} />);
    expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
  });

  test('renders with custom placeholder', () => {
    render(<SearchBar onSearch={vi.fn()} placeholder="Find articles..." />);
    expect(screen.getByPlaceholderText('Find articles...')).toBeInTheDocument();
  });

  test('initializes with provided query', () => {
    render(<SearchBar onSearch={vi.fn()} initialQuery="astro testing" />);
    expect(screen.getByRole('searchbox')).toHaveValue('astro testing');
  });

  test('calls onSearch with trimmed query on submit', async () => {
    const user = userEvent.setup();
    const handleSearch = vi.fn().mockResolvedValue(undefined);

    render(<SearchBar onSearch={handleSearch} />);

    await user.type(screen.getByRole('searchbox'), '  astro islands  ');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(handleSearch).toHaveBeenCalledWith('astro islands');
  });

  test('disables submit button when query is empty', () => {
    render(<SearchBar onSearch={vi.fn()} />);
    expect(screen.getByRole('button', { name: 'Search' })).toBeDisabled();
  });

  test('shows loading state during search', async () => {
    const user = userEvent.setup();
    let resolve: () => void;
    const handleSearch = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r; }));

    render(<SearchBar onSearch={handleSearch} />);

    await user.type(screen.getByRole('searchbox'), 'query');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(screen.getByRole('button', { name: 'Searching...' })).toBeDisabled();
    expect(screen.getByRole('searchbox')).toBeDisabled();

    resolve!();
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Search' })).not.toBeDisabled();
    });
  });
});

Configure Vitest to use jsdom for React tests:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

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

Testing Vue Islands

Vue islands use Vue Test Utils:

npm install -D @vue/test-utils
<!-- src/components/islands/Counter.vue -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min" aria-label="Decrease count">−</button>
    <output class="counter__value">{{ count }}</output>
    <button @click="increment" :disabled="count >= max" aria-label="Increase count">+</button>
    <button @click="reset" class="counter__reset">Reset</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = withDefaults(defineProps<{
  initialCount?: number;
  min?: number;
  max?: number;
}>(), {
  initialCount: 0,
  min: 0,
  max: 10,
});

const emit = defineEmits<{
  change: [value: number];
}>();

const count = ref(props.initialCount);

function increment() {
  if (count.value < props.max) {
    count.value++;
    emit('change', count.value);
  }
}

function decrement() {
  if (count.value > props.min) {
    count.value--;
    emit('change', count.value);
  }
}

function reset() {
  count.value = props.initialCount;
  emit('change', count.value);
}
</script>
// src/components/islands/__tests__/Counter.test.ts
import { mount } from '@vue/test-utils';
import { describe, expect, test } from 'vitest';
import Counter from '../Counter.vue';

describe('Counter Vue island', () => {
  test('renders with initial count', () => {
    const wrapper = mount(Counter, { props: { initialCount: 5 } });
    expect(wrapper.find('output').text()).toBe('5');
  });

  test('increments count on + button click', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 3 } });
    await wrapper.find('[aria-label="Increase count"]').trigger('click');
    expect(wrapper.find('output').text()).toBe('4');
  });

  test('emits change event with new value', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 3 } });
    await wrapper.find('[aria-label="Increase count"]').trigger('click');
    expect(wrapper.emitted('change')).toEqual([[4]]);
  });

  test('disables decrement at minimum', () => {
    const wrapper = mount(Counter, { props: { initialCount: 0, min: 0 } });
    expect(wrapper.find('[aria-label="Decrease count"]').attributes('disabled')).toBeDefined();
  });

  test('disables increment at maximum', () => {
    const wrapper = mount(Counter, { props: { initialCount: 10, max: 10 } });
    expect(wrapper.find('[aria-label="Increase count"]').attributes('disabled')).toBeDefined();
  });

  test('resets to initial count', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 2 } });
    await wrapper.find('[aria-label="Increase count"]').trigger('click');
    await wrapper.find('[aria-label="Increase count"]').trigger('click');
    expect(wrapper.find('output').text()).toBe('4');

    await wrapper.find('.counter__reset').trigger('click');
    expect(wrapper.find('output').text()).toBe('2');
  });
});

Testing client:load vs client:visible Behavior

The client:load, client:idle, client:visible, and client:media directives control when hydration happens. This affects user experience — an island with client:visible won't be interactive until it scrolls into the viewport. Test this behavior with Playwright.

---
// src/pages/hydration-demo.astro
import { SearchBar } from '../components/islands/SearchBar';
import Counter from '../components/islands/Counter.vue';
---

<html>
<body>
  <!-- Hydrates immediately on page load -->
  <section>
    <h2>Load on Page Load</h2>
    <SearchBar client:load onSearch={() => {}} />
  </section>

  <!-- Only hydrates when visible in viewport -->
  <section style="margin-top: 2000px;">
    <h2>Load When Visible</h2>
    <Counter client:visible initialCount={0} />
  </section>
</body>
</html>
// e2e/hydration.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Island hydration timing', () => {
  test('client:load island is immediately interactive', async ({ page }) => {
    await page.goto('/hydration-demo');

    // The SearchBar with client:load should be immediately interactive
    const searchInput = page.getByRole('searchbox');
    await expect(searchInput).toBeEnabled();

    // Type and verify interaction works
    await searchInput.fill('test query');
    await expect(searchInput).toHaveValue('test query');
  });

  test('client:visible island is not hydrated before scrolling', async ({ page }) => {
    await page.goto('/hydration-demo');

    // Counter is below the fold — check it's rendered but not hydrated
    const counter = page.locator('.counter');

    // The HTML is present (SSR) but buttons should be non-interactive
    // before hydration — Playwright waits, so we check quickly
    const isVisible = await counter.isVisible().catch(() => false);

    if (!isVisible) {
      // Not yet in viewport — verify it exists in DOM as static HTML
      const counterHtml = await page.locator('.counter').evaluate(el => el.outerHTML).catch(() => '');
      // The element exists in the DOM from SSR
      expect(counterHtml).toContain('counter');
    }
  });

  test('client:visible island becomes interactive after scrolling', async ({ page }) => {
    await page.goto('/hydration-demo');

    // Scroll to the counter
    await page.locator('.counter').scrollIntoViewIfNeeded();

    // Wait for hydration to complete
    const incrementButton = page.getByRole('button', { name: 'Increase count' });
    await expect(incrementButton).toBeEnabled({ timeout: 5000 });

    // Verify it's interactive
    await incrementButton.click();
    await expect(page.locator('output')).toContainText('1');
  });

  test('client:media island hydrates based on media query', async ({ page }) => {
    // Test at mobile width — island with client:media="(max-width: 768px)" should hydrate
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/mobile-features');

    const mobileMenu = page.locator('.mobile-menu-island');
    await expect(mobileMenu).toBeVisible();

    // At desktop width, the same island should not be hydrated
    await page.setViewportSize({ width: 1200, height: 900 });
    await page.reload();

    // The island should still render HTML but not be interactive
    // (implementation depends on how you handle the non-hydrated state)
  });
});

Testing Island-to-Island Communication

Islands are isolated by design, but they often need to share state. Common patterns include:

  1. Custom events (browser's CustomEvent / dispatchEvent)
  2. Shared stores (nanostores, Zustand with external state)
  3. URL state (query parameters)

Test the custom event pattern:

// src/stores/cart.ts — nanostores for cross-island state
import { atom, computed } from 'nanostores';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const cartItems = atom<CartItem[]>([]);

export const cartTotal = computed(cartItems, items =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export const cartCount = computed(cartItems, items =>
  items.reduce((sum, item) => sum + item.quantity, 0)
);

export function addToCart(item: Omit<CartItem, 'quantity'>) {
  const current = cartItems.get();
  const existing = current.find(i => i.id === item.id);

  if (existing) {
    cartItems.set(current.map(i =>
      i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
    ));
  } else {
    cartItems.set([...current, { ...item, quantity: 1 }]);
  }
}

Unit test the store:

// src/stores/__tests__/cart.test.ts
import { describe, expect, test, beforeEach } from 'vitest';
import { cartItems, cartTotal, cartCount, addToCart } from '../cart';

describe('Cart store', () => {
  beforeEach(() => {
    cartItems.set([]);
  });

  test('adds new item to cart', () => {
    addToCart({ id: '1', name: 'Widget', price: 10 });
    expect(cartItems.get()).toHaveLength(1);
    expect(cartItems.get()[0].quantity).toBe(1);
  });

  test('increments quantity for existing item', () => {
    addToCart({ id: '1', name: 'Widget', price: 10 });
    addToCart({ id: '1', name: 'Widget', price: 10 });

    expect(cartItems.get()).toHaveLength(1);
    expect(cartItems.get()[0].quantity).toBe(2);
  });

  test('calculates total correctly', () => {
    addToCart({ id: '1', name: 'Widget', price: 10 });
    addToCart({ id: '2', name: 'Gadget', price: 25 });

    expect(cartTotal.get()).toBe(35);
  });

  test('calculates count correctly', () => {
    addToCart({ id: '1', name: 'Widget', price: 10 });
    addToCart({ id: '1', name: 'Widget', price: 10 });
    addToCart({ id: '2', name: 'Gadget', price: 25 });

    expect(cartCount.get()).toBe(3);
  });
});

E2E test for island communication:

// e2e/island-communication.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Island-to-island communication via shared store', () => {
  test('adding to cart updates cart icon count', async ({ page }) => {
    await page.goto('/shop');

    const cartCount = page.locator('[data-testid="cart-count"]');
    await expect(cartCount).toContainText('0');

    // Click "Add to Cart" in the ProductCard island
    await page.getByRole('button', { name: 'Add to Cart' }).first().click();

    // The CartIcon island should update its count
    await expect(cartCount).toContainText('1');
  });

  test('cart total updates across islands', async ({ page }) => {
    await page.goto('/shop');

    await page.getByRole('button', { name: 'Add to Cart' }).first().click();
    await page.getByRole('button', { name: 'Add to Cart' }).nth(1).click();

    // Navigate to cart page and verify total
    await page.click('[href="/cart"]');

    const total = page.locator('[data-testid="cart-total"]');
    await expect(total).toBeVisible();
    // Total should reflect both items added
    const totalText = await total.textContent();
    const totalValue = parseFloat(totalText?.replace('$', '') ?? '0');
    expect(totalValue).toBeGreaterThan(0);
  });
});

Svelte Island Testing

npm install -D @testing-library/svelte svelte-jester
<!-- src/components/islands/Toggle.svelte -->
<script lang="ts">
  export let label: string = 'Toggle';
  export let checked: boolean = false;
  export let disabled: boolean = false;

  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher<{ change: boolean }>();

  function handleChange() {
    checked = !checked;
    dispatch('change', checked);
  }
</script>

<label class="toggle" class:toggle--disabled={disabled}>
  <input
    type="checkbox"
    bind:checked
    on:change={handleChange}
    {disabled}
    aria-label={label}
  />
  <span class="toggle__track"></span>
  <span class="toggle__label">{label}</span>
</label>
// src/components/islands/__tests__/Toggle.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { describe, expect, test, vi } from 'vitest';
import Toggle from '../Toggle.svelte';

describe('Toggle Svelte island', () => {
  test('renders unchecked by default', () => {
    const { getByRole } = render(Toggle, { label: 'Dark mode' });
    expect(getByRole('checkbox')).not.toBeChecked();
  });

  test('renders checked when checked prop is true', () => {
    const { getByRole } = render(Toggle, { label: 'Dark mode', checked: true });
    expect(getByRole('checkbox')).toBeChecked();
  });

  test('toggles state on click', async () => {
    const { getByRole } = render(Toggle, { label: 'Dark mode' });
    const checkbox = getByRole('checkbox');

    await fireEvent.click(checkbox);
    expect(checkbox).toBeChecked();

    await fireEvent.click(checkbox);
    expect(checkbox).not.toBeChecked();
  });

  test('dispatches change event with new value', async () => {
    const handleChange = vi.fn();
    const { getByRole, component } = render(Toggle, { label: 'Dark mode' });
    component.$on('change', handleChange);

    await fireEvent.click(getByRole('checkbox'));

    expect(handleChange).toHaveBeenCalledWith(
      expect.objectContaining({ detail: true })
    );
  });

  test('does not toggle when disabled', async () => {
    const { getByRole } = render(Toggle, { label: 'Dark mode', disabled: true });
    const checkbox = getByRole('checkbox');

    await fireEvent.click(checkbox);
    expect(checkbox).not.toBeChecked();
  });
});

Summary: Island Testing Strategy

Layer Tool What it tests
React island unit Vitest + RTL Component logic, state, events
Vue island unit Vitest + Vue Test Utils Component behavior, emits
Svelte island unit Vitest + Testing Library Svelte Reactive state, event dispatch
Astro wrapper Vitest + Container API Props passed to island, SSR output
Hydration timing Playwright client:load vs client:visible
Island communication Playwright Shared store updates across islands
Shared store Vitest State management logic

The key insight: test each layer with the right tool. Don't use Playwright to test React component logic — that's what unit tests are for. Don't use Vitest to test hydration timing — that requires a real browser. Matching the test type to the concern keeps your suite fast, reliable, and easy to debug.

Read more

Start now free