Testing HTMX 2 with Happy Dom

January 2026

I have a long running e2e test setup with Playwright that takes around 30 seconds to complete on my local computer and around two minutes in CI. Since I have adopted HTMX I am trying to find a better approach for testing HTMX interactivity without having to work in a real, slow browser environment.

The ~400 other unit tests execute in under a second - why can't I also run interactive tests via HTMX in under a second?

So I gave Happy Dom a shot as it's supposed to be faster then the other popular JSDOM alternatives. However, I hit some limitations in the process. First off, Happy Dom does not implement the XPathEvaluator that HTMX 2 needs:

ReferenceError: XPathEvaluator is not defined
      at <anonymous> (/.../public/libs/htmx-2.0.7.min.js:1:24685)

Since I was not using XPaths at all I mocked the XPathEvaluator implementation with a stub (see code appendix).

In the test itself, I just apply the html to the Happy Dom document like this:

document.body.innerHTML = html;

However, I faced a second issue with an TypeError: window[PropertySymbol.dispatchError] is not a function. error which turns out as a Happy Dom limitation due to the onclick listeners that are directly assigned in some places:

<button onclick="window.scrollTo({top: 0, behavior: 'smooth'})"></button>

I had to refactor those instances, in my case to hyperscript which looks like this:

<button _="on click go to the top of the body smoothly"></button>

And then it works! With bun the initial test ran in around 20ms and the existing Playwright test in around 2000ms making the non-browser version around 100x faster on first sight.

To not execute too many scripts I added an allow-list of scripts that I wanted to execute (see code appendix).

Conclusion

I am happy that I am able to test HTMX interactions without having to spin up a full browser. Going forward I hope this can guide someone into a better testing setup for HTMX applications.

I'll now wander off to migrate more tests into that new setup.

Code Appendix

This is the code for the stub:

class XPathEvaluatorPolyfill {
  createExpression(expression: string): XPathExpression {
    return new XPathExpressionPolyfill(expression);
  }
}

class XPathExpressionPolyfill {
  constructor(private expression: string) {}

  evaluate(
    contextNode: Node,
    type: number,
    result: XPathResult | null,
  ): XPathResult {
    // For now, return an empty result since HTMX primarily uses CSS selectors
    // and only uses XPath for advanced attribute queries
    return new XPathResultPolyfill([]) as unknown as XPathResult;
  }
}

class XPathResultPolyfill {
  resultType = 5;
  private index = 0;

  // Add required XPathResult properties (unused but needed for interface)
  booleanValue = false;
  invalidIteratorState = false;
  numberValue = 0;
  singleNodeValue = null;
  stringValue = '';
  snapshotLength = 0;

  ANY_TYPE = 0;
  NUMBER_TYPE = 1;
  STRING_TYPE = 2;
  BOOLEAN_TYPE = 3;
  UNORDERED_NODE_ITERATOR_TYPE = 4;
  ORDERED_NODE_ITERATOR_TYPE = 5;
  UNORDERED_NODE_SNAPSHOT_TYPE = 6;
  ORDERED_NODE_SNAPSHOT_TYPE = 7;
  ANY_UNORDERED_NODE_TYPE = 8;
  FIRST_ORDERED_NODE_TYPE = 9;

  constructor(private nodes: Node[]) {
    this.snapshotLength = nodes.length;
  }

  iterateNext(): Node | null {
    if (this.index < this.nodes.length) {
      return this.nodes[this.index++];
    }
    return null;
  }

  snapshotItem(index: number): Node | null {
    return this.nodes[index] || null;
  }
}

// Adding to global
globalThis.XPathEvaluator = XPathEvaluatorPolyfill;

Happy Dom setup as global registration:

// Whitelist of scripts that should be loaded during tests
// Scripts not in this list will be blocked to prevent errors from missing browser APIs
const SCRIPT_WHITELIST = ['htmx', 'hyperscript'];

// Helper to check if a script path matches the whitelist
const isScriptAllowed = (pathname: string): boolean => {
  return SCRIPT_WHITELIST.some(allowed => pathname.includes(allowed));
};

// App is a mocked implementation of the web server (in this case Hono with Bun)
const app = createApplication();

GlobalRegistrator.register({
  settings: {
    enableJavaScriptEvaluation: true,
    suppressInsecureJavaScriptEnvironmentWarning: true,
    disableCSSFileLoading: true,
    disableJavaScriptFileLoading: false,
    handleDisabledFileLoadingAsSuccess: true,
    fetch: {
      virtualServers: [
        {
          url: 'http://test.local/libs/',
          directory: './public/libs',
        },
      ],
      interceptor: {
        beforeAsyncRequest: async ({ request, window }) => {
          const url = new URL(request.url);

          if (url.pathname.startsWith('/libs/')) {
            if (isScriptAllowed(url.pathname)) {
              // Let virtualServers handle it
              return undefined;
            } else {
              // Return empty response to prevent script execution errors
              return new window.Response('', {
                status: 200,
                statusText: 'OK',
                headers: { 'Content-Type': 'application/javascript' },
              });
            }
          }

          // Build headers object from happy-dom's headers
          const headersObj: Record<string, string> = {};
          request.headers.forEach((value: string, key: string) => {
            headersObj[key] = value;
          });

          // Forward request to Hono app using URL string
          const honoResponse = await app.request(url.pathname, {
            method: request.method,
            headers: headersObj,
            body: request.body as unknown as BodyInit,
          });

          // Convert Hono Response to happy-dom Response
          const responseText = await honoResponse.text();

          const responseHeaders: Record<string, string> = {};
          honoResponse.headers.forEach((value, key) => {
            responseHeaders[key] = value;
          });

          const happyDomResponse = new window.Response(responseText, {
            status: honoResponse.status,
            statusText: honoResponse.statusText,
            headers: responseHeaders,
          });

          return happyDomResponse;
        },
      },
    },
  },
});