HTMX + Alpine.js Testing: Unit and Integration Patterns
HTMX handles server communication and DOM swaps; Alpine.js handles client-side interactivity within those swapped elements. Testing them together requires three layers: Alpine.js component unit tests (using @alpinejs/test), HTMX server-side partial tests (using your backend test client), and E2E tests (Playwright) for the full interaction cycle.
Key Takeaways
Test Alpine.js components with @alpinejs/test. The official testing utility lets you render Alpine components in jsdom, trigger events, and assert on state — without a browser.
HTMX and Alpine play different roles. HTMX owns the network layer and HTML swaps; Alpine owns component state and DOM interactions. Test each in isolation, then test the handoff in E2E tests.
Alpine's x-data initializes per-element. After HTMX swaps in new HTML, Alpine auto-initializes any x-data attributes in the swapped content. E2E tests verify this handoff.
@alpinejs/test wraps the DOM lifecycle. Use render() to mount an Alpine component, fireEvent() to trigger interactions, and Alpine.store() to assert on global store state.
Playwright is essential for HTMX+Alpine interaction. HTMX response → DOM swap → Alpine initialization is a three-step chain that only works in a real browser with JavaScript running.
How HTMX and Alpine.js Divide Responsibilities
<!-- HTMX fetches a task from the server -->
<div hx-get="/tasks/1" hx-trigger="load" hx-swap="outerHTML">
Loading...
</div>
<!-- Server returns this HTML fragment; Alpine.js takes over inside it -->
<div x-data="taskCard({ id: 1, title: 'Write tests', completed: false })">
<h3 x-text="title"></h3>
<button @click="toggle()" x-text="completed ? 'Undo' : 'Complete'"></button>
<span x-show="completed" class="badge">Done</span>
</div>- HTMX: fetches
/tasks/1, swaps the outer div with the server response - Alpine.js: initializes
taskCardon the swapped element, handles the toggle button
Alpine.js Unit Testing Setup
npm install --save-dev @alpinejs/test alpinejs// vitest.config.js or jest.config.js
export default {
testEnvironment: 'jsdom',
globals: true
}Testing Alpine.js Components
// task-card.js — Alpine component definition
import Alpine from 'alpinejs';
export function taskCard(data) {
return {
id: data.id,
title: data.title,
completed: data.completed,
async toggle() {
const response = await fetch(`/tasks/${this.id}/toggle`, {
method: 'POST'
});
const updated = await response.json();
this.completed = updated.completed;
},
get statusLabel() {
return this.completed ? 'Done' : 'Pending';
}
};
}
Alpine.data('taskCard', taskCard);// task-card.test.js
import { render, fireEvent } from '@alpinejs/test';
import { taskCard } from './task-card';
describe('taskCard Alpine component', () => {
it('renders initial state', async () => {
const { getByText } = await render(`
<div x-data="taskCard({ id: 1, title: 'Write tests', completed: false })">
<h3 x-text="title"></h3>
<button x-text="completed ? 'Undo' : 'Complete'"></button>
<span x-show="completed">Done</span>
</div>
`, {
data: { taskCard }
});
expect(getByText('Write tests')).toBeTruthy();
expect(getByText('Complete')).toBeTruthy();
expect(document.querySelector('span[x-show]').style.display).toBe('none');
});
it('computed statusLabel reflects completed state', () => {
const component = taskCard({ id: 1, title: 'Test', completed: false });
expect(component.statusLabel).toBe('Pending');
component.completed = true;
expect(component.statusLabel).toBe('Done');
});
it('toggles completed state on click', async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ completed: true })
});
const { getByText } = await render(`
<div x-data="taskCard({ id: 1, title: 'Task', completed: false })">
<button @click="toggle()" x-text="completed ? 'Undo' : 'Complete'"></button>
</div>
`, { data: { taskCard } });
await fireEvent.click(getByText('Complete'));
expect(getByText('Undo')).toBeTruthy();
expect(fetch).toHaveBeenCalledWith('/tasks/1/toggle', { method: 'POST' });
});
});Testing Alpine.js with Alpine.store()
// stores/cart.js
import Alpine from 'alpinejs';
Alpine.store('cart', {
items: [],
add(item) {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({ ...item, quantity: 1 });
}
},
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
get count() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
});// stores/cart.test.js
import Alpine from 'alpinejs';
import './stores/cart';
describe('cart store', () => {
beforeEach(() => {
Alpine.store('cart').items = [];
});
it('adds new items', () => {
const cart = Alpine.store('cart');
cart.add({ id: 1, name: 'Widget', price: 10 });
expect(cart.items).toHaveLength(1);
expect(cart.count).toBe(1);
});
it('increments quantity for existing items', () => {
const cart = Alpine.store('cart');
cart.add({ id: 1, name: 'Widget', price: 10 });
cart.add({ id: 1, name: 'Widget', price: 10 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
expect(cart.count).toBe(2);
});
it('calculates total correctly', () => {
const cart = Alpine.store('cart');
cart.add({ id: 1, name: 'Widget', price: 10 });
cart.add({ id: 2, name: 'Gadget', price: 25 });
expect(cart.total).toBe(35);
});
});Testing HTMX Server Side (Backend)
The HTMX server tests remain backend-specific (see other guides for Django, Rails, FastAPI, Go). What's important for the HTMX+Alpine combo:
# Django example: partial must include Alpine x-data attributes
def test_task_partial_includes_alpine_init(client, task):
response = client.get('/tasks/', HTTP_HX_REQUEST='true')
body = response.content.decode()
# Alpine must be initialized on swapped-in elements
assert 'x-data="taskCard(' in body
assert f'"id": {task.pk}' in body
assert f'"title": "{task.title}"' in bodyTesting the HTMX → Alpine Handoff
The most critical thing to test: when HTMX swaps in new HTML, Alpine initializes correctly on the new elements.
// integration-test.js
import Alpine from 'alpinejs';
import htmx from 'htmx.org';
// Mock HTMX swap (simplified)
function simulateHTMXSwap(targetEl, newHTML) {
targetEl.outerHTML = newHTML;
// HTMX fires htmx:afterSettle after swaps
document.dispatchEvent(new CustomEvent('htmx:afterSettle', {
detail: { target: document.body }
}));
// Alpine needs to be initialized on new elements
Alpine.initTree(document.body);
}
describe('HTMX + Alpine handoff', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="container">Loading...</div>';
Alpine.start();
});
it('Alpine initializes on HTMX-swapped content', async () => {
const container = document.getElementById('container');
simulateHTMXSwap(container, `
<div id="container" x-data="{ count: 0 }">
<button @click="count++">Click</button>
<span x-text="count"></span>
</div>
`);
const span = document.querySelector('span[x-text]');
const button = document.querySelector('button');
// Alpine should have initialized
expect(span.textContent).toBe('0');
button.click();
await Alpine.nextTick();
expect(span.textContent).toBe('1');
});
});Testing Magic Properties ($store, $dispatch, $el)
describe('Alpine magic properties', () => {
it('$store reads from Alpine global store', async () => {
Alpine.store('ui', { theme: 'dark' });
const { getByText } = await render(`
<div x-data>
<span x-text="$store.ui.theme"></span>
</div>
`);
expect(getByText('dark')).toBeTruthy();
});
it('$dispatch triggers custom events', async () => {
const events = [];
document.addEventListener('cart:added', e => events.push(e.detail));
await render(`
<div x-data>
<button @click="$dispatch('cart:added', { itemId: 42 })">Add</button>
</div>
`);
document.querySelector('button').click();
await Alpine.nextTick();
expect(events).toHaveLength(1);
expect(events[0].itemId).toBe(42);
});
});E2E Testing with Playwright
The most important tests: verify the full HTMX request → swap → Alpine initialization cycle.
// tests/e2e/task-flow.spec.js
import { test, expect } from '@playwright/test';
test('HTMX loads task and Alpine initializes', async ({ page }) => {
await page.goto('/tasks');
// Wait for HTMX to load the task list
await page.waitForSelector('[x-data]'); // Alpine initialized
// Alpine-powered toggle works on HTMX-loaded content
const completeBtn = page.locator('button').filter({ hasText: 'Complete' }).first();
await completeBtn.click();
await expect(completeBtn).toHaveText('Undo');
});
test('HTMX swap reinitializes Alpine components', async ({ page }) => {
await page.goto('/tasks');
// Add a task via HTMX form
await page.fill('[name="title"]', 'New Alpine task');
await page.click('[type="submit"]');
// Wait for HTMX to inject new task
const newTask = page.locator('[data-testid="task-item"]').filter({ hasText: 'New Alpine task' });
await expect(newTask).toBeVisible();
// Alpine should be initialized on the new task — Complete button works
const toggleBtn = newTask.locator('button');
await toggleBtn.click();
await expect(toggleBtn).toHaveText('Undo');
});
test('Alpine store persists across HTMX page transitions', async ({ page }) => {
await page.goto('/products');
// Add to cart via Alpine store
await page.click('[data-testid="add-to-cart"]');
// HTMX navigates to next page (hx-boost or hx-get)
await page.click('[href="/checkout"]');
await page.waitForURL('**/checkout');
// Alpine store should persist (if using localStorage or $persist)
const count = await page.locator('[data-testid="cart-count"]').textContent();
expect(parseInt(count ?? '0')).toBeGreaterThan(0);
});Testing Alpine with x-init and Template Events
it('x-init runs after HTMX swap', async ({ page }) => {
await page.goto('/dashboard');
// Track calls to x-init-triggered function
await page.addInitScript(() => {
window.initCalls = [];
window.trackInit = (name) => window.initCalls.push(name);
});
// HTMX loads a widget with x-init
await page.click('[hx-get="/widgets/chart"]');
await page.waitForSelector('[data-widget="chart"]');
const initCalls = await page.evaluate(() => window.initCalls);
expect(initCalls).toContain('chart');
});HelpMeTest for HTMX + Alpine Apps
The combination of HTMX + Alpine creates testing challenges:
- Alpine re-initializes on every HTMX swap
- Store state can persist or reset depending on the swap type
x-cloakelements flicker if Alpine loads too slowly- Custom Alpine plugins may conflict with HTMX response timing
HelpMeTest tests the full user journey against your real app:
When the user adds a product to the cart (Alpine store)
And navigates to the cart page (HTMX boost)
Then the cart shows the correct item and quantity
And the total price is calculated correctlyNo Playwright configuration needed — HelpMeTest handles it.
Summary
Testing HTMX + Alpine.js requires three layers:
Alpine unit tests (@alpinejs/test):
- Render Alpine components in jsdom
- Test reactivity, computed props, event handlers
- Test
Alpine.store()in isolation
HTMX server tests (backend):
- Verify partials contain Alpine
x-datainitialization data - Test all HTMX response headers
E2E tests (Playwright):
- Verify HTMX swap → Alpine initialization chain
- Test Alpine store persistence across HTMX navigation
- Verify
x-initruns after swap - Test full user flows end-to-end