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:
- The island component itself (React/Vue/Svelte code) — testable with framework-native tools
- The Astro wrapper (how the island is embedded and what props it receives) — testable with the container API
- Hydration behavior (does
client:loadhydrate immediately? doesclient:visiblewait?) — requires Playwright - 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:
- Custom events (browser's
CustomEvent/dispatchEvent) - Shared stores (nanostores, Zustand with external state)
- 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.