HTMX Testing Guide: Playwright and Server-Side Assertions (2026)

HTMX Testing Guide: Playwright and Server-Side Assertions (2026)

HTMX flips the standard SPA model: instead of JSON APIs and client-side rendering, the server returns HTML fragments that swap into the page. The JavaScript footprint is tiny, but the testing surface is real — swap targets, trigger events, out-of-band swaps, request headers, and server response formats all need verification.

Standard JavaScript testing tools like Vitest don't fit well here because there's little client-side logic to test in isolation. HTMX testing lives at two levels: server route tests that verify HTML response shape, and Playwright E2E tests that verify the browser interaction.

What to Test in HTMX Apps

HTMX behavior is driven by HTML attributes (hx-get, hx-post, hx-target, hx-swap). Bugs in HTMX apps usually come from:

  1. Server returning wrong HTML shape — the swap target exists but the content is malformed
  2. Wrong swap strategyhx-swap="innerHTML" when you wanted outerHTML, or vice versa
  3. Request headers not handled — HTMX sends HX-Request: true; server doesn't check it and returns full page HTML instead of a fragment
  4. Out-of-band swaps not triggeringhx-swap-oob="true" elements that are missing from the response
  5. Trigger conditionshx-trigger="change delay:300ms" that fires too early or not at all

Test strategy: use server unit tests for HTML fragment correctness, and Playwright for browser-level interaction verification.

Server Route Testing (Express Example)

Test your HTMX endpoints as HTTP routes. The key assertion is that when HX-Request: true is present, the server returns an HTML fragment, not a full page.

// server/routes/todos.js
const express = require('express');
const router = express.Router();

let todos = [
  { id: 1, text: 'Write tests', done: false },
  { id: 2, text: 'Ship feature', done: false },
];

function renderTodoItem(todo) {
  return `
    <li id="todo-${todo.id}" class="${todo.done ? 'done' : ''}">
      <input type="checkbox" 
             hx-post="/todos/${todo.id}/toggle" 
             hx-target="#todo-${todo.id}" 
             hx-swap="outerHTML"
             ${todo.done ? 'checked' : ''} />
      <span>${todo.text}</span>
      <button hx-delete="/todos/${todo.id}" 
              hx-target="#todo-${todo.id}" 
              hx-swap="outerHTML swap:0.5s">Delete</button>
    </li>
  `.trim();
}

router.get('/', (req, res) => {
  const isHtmx = req.headers['hx-request'] === 'true';
  if (isHtmx) {
    return res.send(todos.map(renderTodoItem).join('\n'));
  }
  res.render('todos', { todos });
});

router.post('/:id/toggle', (req, res) => {
  const todo = todos.find((t) => t.id === parseInt(req.params.id));
  if (!todo) return res.status(404).send('');
  todo.done = !todo.done;
  res.send(renderTodoItem(todo));
});

router.delete('/:id', (req, res) => {
  const idx = todos.findIndex((t) => t.id === parseInt(req.params.id));
  if (idx === -1) return res.status(404).send('');
  todos.splice(idx, 1);
  res.send('');
});

module.exports = router;
// server/routes/todos.test.js
const request = require('supertest');
const express = require('express');
const todosRouter = require('./todos');

function createApp() {
  const app = express();
  app.use(express.json());
  app.use('/todos', todosRouter);
  return app;
}

describe('GET /todos', () => {
  it('returns a fragment when HX-Request header is present', async () => {
    const res = await request(createApp())
      .get('/todos')
      .set('HX-Request', 'true');

    expect(res.status).toBe(200);
    expect(res.text).toContain('<li id="todo-');
    expect(res.text).not.toContain('<html>');
    expect(res.text).not.toContain('<!DOCTYPE');
  });

  it('returns a full page without HX-Request header', async () => {
    const res = await request(createApp()).get('/todos');
    expect(res.status).toBe(200);
    // Without HX-Request, server renders full page
  });
});

describe('POST /todos/:id/toggle', () => {
  it('returns the updated todo item HTML', async () => {
    const res = await request(createApp())
      .post('/todos/1/toggle')
      .set('HX-Request', 'true');

    expect(res.status).toBe(200);
    expect(res.text).toContain('id="todo-1"');
    expect(res.text).toContain('checked');
    expect(res.text).toContain('hx-post="/todos/1/toggle"');
  });

  it('returns 404 for a non-existent todo', async () => {
    const res = await request(createApp())
      .post('/todos/9999/toggle')
      .set('HX-Request', 'true');

    expect(res.status).toBe(404);
  });
});

describe('DELETE /todos/:id', () => {
  it('returns 200 with empty body on successful delete', async () => {
    const res = await request(createApp())
      .delete('/todos/1')
      .set('HX-Request', 'true');

    expect(res.status).toBe(200);
    expect(res.text).toBe('');
  });
});

Install supertest: npm install -D supertest. These tests run without a browser, are fast, and catch server-side bugs before Playwright ever runs.

Playwright E2E for HTMX

Server tests verify that the HTML is correct. Playwright tests verify that HTMX actually wires the HTML attributes to browser behavior — the swap happens at the right target, the trigger fires, and the page updates without a full reload.

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

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'node server.js',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:3000',
  },
});
// e2e/todos.test.ts
import { test, expect } from '@playwright/test';

test.describe('HTMX todo list', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/todos');
  });

  test('initial todo list renders from server', async ({ page }) => {
    // Items loaded from server on page load (or via hx-get on DOMContentLoaded)
    await expect(page.locator('li[id^="todo-"]')).toHaveCount({ min: 1 });
  });

  test('toggling a todo updates the list item in place', async ({ page }) => {
    const firstCheckbox = page.locator('li[id^="todo-"] input[type="checkbox"]').first();
    const todoItem = page.locator('li[id^="todo-"]').first();

    // Item should not have done class initially
    await expect(todoItem).not.toHaveClass(/done/);

    // Click the checkbox — triggers hx-post, server returns updated item
    await firstCheckbox.click();

    // HTMX swaps the item in place; verify the done class appears
    await expect(todoItem).toHaveClass(/done/);

    // No page navigation happened
    await expect(page).toHaveURL('/todos');
  });

  test('deleting a todo removes it from the DOM', async ({ page }) => {
    const items = page.locator('li[id^="todo-"]');
    const count = await items.count();

    const firstDeleteButton = page.locator('li[id^="todo-"] button').first();
    await firstDeleteButton.click();

    // HTMX swaps the item out with empty content; item count should decrease
    await expect(items).toHaveCount(count - 1);
  });
});

Testing hx-boost and Navigation

hx-boost="true" converts <a> tags into HTMX requests — the browser fetches the target page as a fragment and swaps it in. Test this to confirm full-page reloads don't happen.

// e2e/navigation.test.ts
import { test, expect } from '@playwright/test';

test('hx-boost navigation does not trigger a full page reload', async ({ page }) => {
  await page.goto('/');

  // Track whether a full navigation (non-HTMX) happens
  let fullNavigationOccurred = false;
  page.on('framenavigated', (frame) => {
    if (frame === page.mainFrame()) {
      fullNavigationOccurred = true;
    }
  });

  // Click a boosted link
  await page.click('a[hx-boost="true"]');

  // HTMX swap happened — URL updated via pushState, no full navigation
  expect(fullNavigationOccurred).toBe(false);
  await expect(page).not.toHaveURL('/');
});

Testing Out-of-Band Swaps

Out-of-band swaps (hx-swap-oob) let a single HTMX response update multiple page regions. Test that both the primary target and the OOB target are updated.

// e2e/oob-swap.test.ts
import { test, expect } from '@playwright/test';

test('adding a todo updates the list and the count badge', async ({ page }) => {
  await page.goto('/todos');

  const countBadge = page.locator('#todo-count');
  const initialCount = parseInt(await countBadge.textContent() ?? '0');

  // Submit the new todo form
  await page.fill('#new-todo-input', 'OOB test task');
  await page.click('#add-todo-button');

  // Primary swap: new item appears in the list
  await expect(page.getByText('OOB test task')).toBeVisible();

  // OOB swap: count badge updated without a page reload
  const newCount = parseInt(await countBadge.textContent() ?? '0');
  expect(newCount).toBe(initialCount + 1);
});

Testing HTMX Request Headers

HTMX sends several headers on every request. If your server logic depends on them, verify they're present using Playwright's route interception.

// e2e/htmx-headers.test.ts
import { test, expect } from '@playwright/test';

test('HTMX requests include required headers', async ({ page }) => {
  const capturedHeaders: Record<string, string> = {};

  await page.route('**/todos/**', (route) => {
    const headers = route.request().headers();
    Object.assign(capturedHeaders, headers);
    route.continue();
  });

  await page.goto('/todos');
  await page.locator('li[id^="todo-"] input[type="checkbox"]').first().click();

  expect(capturedHeaders['hx-request']).toBe('true');
  expect(capturedHeaders['hx-target']).toMatch(/^todo-\d+$/);
  expect(capturedHeaders['hx-trigger']).toBeDefined();
});

What Tests Won't Catch

  • A swap that works in Chrome but fails in Safari due to a CSS transition timing difference
  • A debounced trigger (hx-trigger="keyup delay:500ms") that fires correctly in isolation but causes race conditions under slow network conditions
  • A server fragment that's syntactically valid HTML but missing a required attribute (hx-target) causing silent swap failures
  • A production CDN caching an HTMX fragment response that was meant to be dynamic

These appear in production. Monitoring catches them.

Monitoring HTMX Apps in Production with HelpMeTest

Install HelpMeTest:

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

Write plain-English tests that run against your live app:

Go to https://myhtmxapp.com/todos
Verify at least one todo item is visible
Click the checkbox on the first todo item
Verify the first todo item has the done class
Click the delete button on the second todo item
Verify the second todo item is no longer visible

HelpMeTest runs these every few minutes in a real browser. If your HTMX swap stops working in production — the server starts returning 500s for fragments, a swap target gets a wrong selector, an OOB swap silently fails — you know in minutes.

Free tier: 10 tests, unlimited health checks. Pro: $100/month for unlimited tests and parallel execution.

Your route tests prove the HTML fragments are correct. Playwright proves the swaps work in the browser. HelpMeTest proves the deployed app is working right now.


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

Read more