Community member Jonathan Channon recently approached me about an appropriate way to test your Htmx-powered applications using the Playwright testing framework. It can be annoying to get the timing right between sending an HTMX request, processing it on the server, and applying it to the page.

In this post, we’ll see a surefire way to wait for Htmx to finish before testing the state of your pages, thus leading to more reliable tests and faster test execution. Let’s go.

The Htmx Counter Application

Let’s first see what application we’ll be testing. It’s a simple **Counter ** component that increases in value when the user presses a button.

@model HtmxPlaywrightIntegration.ViewModels.CounterViewModel

<div id="counter" class="card">
    <div id="value" class="card-body">
        @Model.Count
    </div>
    <div class="card-footer">
        <form asp-page="Index" method="post" 
              hx-post 
              hx-target="#counter"
              hx-swap="outerHTML">
            <button class="btn btn-primary">Increment</button>
        </form>
    </div>
</div>

The ASP.NET Core endpoint is straightforward.

using HtmxPlaywrightIntegration.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace HtmxPlaywrightIntegration.Pages;

public class IndexModel(ILogger<IndexModel> logger) : PageModel
{
    public static CounterViewModel Value { get; set; }
        = new();

    public void OnGet()
    {
    }

    public IActionResult OnPost()
    {
        Value.Count += 1;
        return Partial("_Counter", Value);
    }
}

Clicking the button will make a request to the server, increment the value, and return the HTML snippet to be processed into the page.

Now, let’s move on to the next part of the process, the Htmx lifecycle.

Htmx Request Lifecycle Events

Htmx contains multiple lifecycle events that we can utilize during a request. These events allow us to modify outgoing requests and understand what stages Htmx is at during the processing phase.

The lifecycle event we are most interested in is htmx:afterSettle. Settling is the process after all DOM changes have been applied and the page is now stable. Let’s hook into this page event and write a console message. I’ve added this to the typical site.js file, but it can go anywhere within your application.

document.body.addEventListener('htmx:afterSettle', function(evt) {
    console.log('playwright:continue');
});

Now, whenever we settle the page, a console message will be written with the value of playwright:continue. We’ll see how to register this script into our page in the next section using the Playwright APIs.

Playwright and Htmx Extensions

Now, let’s look at our test.

namespace HtmxPlaywrightIntegration.Tests;

[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class Tests : PageTest
{
    [Test]
    public async Task CanIncrementCountUsingHtmx()
    {
        await Page.GotoAsync("http://localhost:5170");

        await Page.RegisterHtmxLifecycleListener();

        var button = Page.Locator("text=Increment");
        var body = Page.Locator("#value");

        var currentCount = int.Parse(await body.TextContentAsync() ?? "-1");

        await button.ClickAsync();
        await Page.WaitForHtmx();
        
        await Expect(body).ToHaveTextAsync($"{currentCount+1}");
    }
}

We first call the RegisterHtmxLifecycleListener script. This is the same JavaScript seen above. Then, we call WaitForHtmx, which will wait for the console message in our page’s output. Let’s see how these extension methods work.

using Microsoft.Playwright;

namespace HtmxPlaywrightIntegration.Tests;

public static class HtmxExtensions
{
    private const string Continue = "playwright:continue";
    
    public static Task WaitForHtmx(this IPage page)
    {
        return page.WaitForConsoleMessageAsync(new() {
            Predicate = message => message.Text == Continue
        });
    }

    public static Task RegisterHtmxLifecycleListener(this IPage page)
    {
        return page.AddScriptTagAsync(new()
        {
            // language=javascript
            Content = $$"""
                      document.body.addEventListener('htmx:afterSettle', function(evt) {
                          console.log('{{Continue}}');
                      });
                      """
        });
    }
}

It’s that easy. Now, your Playwright tests can wait for messages generated by the Htmx lifecycle, so you don’t have to worry about changes to your implementation on the front or back end changing how your tests execute. Additionally, you won’t have to waste time waiting for random delays to progress through your tests.

I hope you found this post helpful. Have fun building Htmx apps tested by Playwright. Cheers.