Testing HTMX apps in the real browser with TWD

2 28 56
calendar_today agoschedule2 min read
— Originally published at dev.to

HTMX apps are refreshingly simple: the server returns HTML fragments, and HTMX swaps them into the page in response to hx-get, hx-post, and friends. There is usually no bundler and no client framework. That simplicity is the point, and it is also why the usual testing tools feel wrong. A jsdom unit test cannot see an HTMX swap, and a full end-to-end runner is a lot of machinery to bolt onto an app that deliberately avoids machinery.

twd-js fits the HTMX model well. It runs tests inside the real browser, so it sees exactly what HTMX does to the DOM, and it installs from a CDN with an import map, so it adds no build step of its own.

TWD sidebar running tests inside an HTMX app

Setup: load both from a CDN

Load HTMX as usual, then add an import map for TWD next to it. esm.sh resolves TWD's internal dependency, so there is nothing else to wire up.

<script src="https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js"></script>
<script type="importmap">
  {
    "imports": {
      "twd-js": "https://esm.sh/twd-js@1.8.2",
      "twd-js/runner": "https://esm.sh/twd-js@1.8.2/runner",
      "twd-js/bundled": "https://esm.sh/twd-js@1.8.2/bundled"
    }
  }
</script>

Your markup stays pure HTMX:

<button hx-get="/api/todos" hx-target="#todo-list" hx-swap="innerHTML">Load todos</button>
<ul id="todo-list"></ul>

Test against the real backend

The important design choice for HTMX is what to assert against. Because HTMX endpoints return HTML fragments rather than JSON, the cleanest approach is to run the tests against your real HTML-returning backend and reset it between tests, rather than mocking. This is the same pattern the Nuxt example uses for real-backend testing.

A tiny dev-only reset endpoint gives every test a clean starting point:

import { twd, userEvent, screenDom } from 'twd-js';
import { describe, it, beforeEach } from 'twd-js/runner';

describe('Todo List (HTMX)', () => {
  beforeEach(async () => {
    await fetch('/api/reset', { method: 'POST' });
  });

  it('loads the seeded todos', async () => {
    await twd.visit('/');

    const loadButton = await screenDom.getByRole('button', { name: 'Load todos' });
    await userEvent.click(loadButton);

    // HTMX swaps asynchronously, so use findBy* (which waits) rather than getBy*.
    const todo = await screenDom.findByText('Learn TWD');
    twd.should(todo, 'be.visible');
  });
});

The one detail worth internalizing: HTMX swaps happen after an async request, and there is no mock to await, so query with Testing Library's findBy* (which retries until the element appears) instead of the synchronous getBy*.

A note on mocking

TWD's service worker will happily intercept HTMX's requests, the same as any fetch or XHR. Today twd.mockRequest serializes responses as JSON, which is a natural fit for JSON APIs but not for HTML fragments, so for HTMX we recommend the real-backend approach above. First-class HTML-fragment mocking for hypermedia frameworks is on the roadmap.

Try it

A complete example, an HTMX todo list with load, create, and delete, a small Express backend with a reset endpoint, in-browser tests, and CI, lives in twd-htmx. It is served as static files with TWD loaded straight from the CDN, so you can read the whole thing top to bottom without a build step in sight.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

Local-First: The Browser as the Vault

Pocket Portfolio - Apr 20

5 Web Dev Pitfalls That Are Silently Killing Your Projects (With Real Fixes)

Dharanidharan - Mar 3

Test your vanilla JS app in the real browser, with no build step

Kevin Martinez - Jul 24
chevron_left
2.7k Points86 Badges
Spaintwd.dev
36Posts
12Comments
12Connections
Javascript developer and enthusiastic about web development and learning every day

Commenters (This Week)

2 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!