#
Testing htmx Applications
A well-tested application gives confidence that changes won't break existing functionality. Testing htmx applications requires techniques beyond traditional web testing because htmx fundamentally changes how pages update. This chapter covers testing strategies from unit tests through full browser automation, providing patterns you can apply to any htmx project.
#
22.1 Introduction
#
Why Testing htmx Applications Requires Special Consideration
Traditional ASP.NET Core testing focuses on complete page responses. You request a URL, receive HTML, and verify the content. htmx applications work differently in several ways that affect testing strategy.
Partial Responses: Most htmx requests return HTML fragments, not complete pages. A handler might return just a table row or a form, without the surrounding layout. Tests must verify these fragments contain the correct content and htmx attributes without expecting full page structure.
htmx Attributes Drive Behavior: The attributes on HTML elements determine what htmx does. A missing hx-target or incorrect hx-swap value causes bugs that don't produce server errors. Tests must verify these attributes exist and have correct values.
Dynamic DOM Updates: htmx replaces, appends, or removes DOM elements based on server responses. Testing that a search filter works requires verifying not just that the server returns correct data, but that the browser correctly updates the visible page.
Out-of-Band Updates: A single response can update multiple page regions through OOB swaps. Tests must parse responses to find OOB elements and verify they target the correct elements with correct content.
Client-Side Interactions: Hyperscript behaviors, keyboard shortcuts, and timed actions like toast auto-dismiss happen entirely in the browser. Unit and integration tests can't verify these; you need browser automation.
#
The Testing Pyramid for htmx Applications
The testing pyramid remains valid for htmx applications, but the middle layer (integration tests) becomes more important.
╱╲
╱ ╲
╱ ╲ Browser Tests
╱ Few ╲ Full interactions, Hyperscript, visual verification
╱────────╲
╱ ╲
╱ ╲ Integration Tests
╱ Many ╲ Handlers, partials, htmx attributes, OOB updates
╱────────────────╲
╱ ╲
╱ Most ╲ Unit Tests
╱ ╲ Services, models, helpers, validation
╱══════════════════════╲
Unit Tests verify business logic in isolation: service methods, view model calculations, helper functions. These run fast and catch logic errors early.
Integration Tests verify that Razor Page handlers return correct partial HTML with proper htmx attributes. This layer is larger for htmx applications than traditional MVC because so much behavior depends on the HTML structure and attributes.
Browser Tests verify that htmx actually performs the expected updates in a real browser. These are slower but necessary for testing dynamic interactions, Hyperscript behaviors, and complex multi-step workflows.
#
What This Chapter Covers
This chapter walks through testing at each level:
- Unit testing services, view models, and helper methods
- Setting up integration test infrastructure with WebApplicationFactory
- Testing partial responses and verifying htmx attributes
- Testing response headers (HX-Trigger, HX-Push-Url)
- Parsing and testing OOB updates
- Browser automation with Playwright for dynamic interactions
- Testing Hyperscript behaviors and keyboard interactions
- Testing error scenarios and validation
- Organizing tests and running them in CI/CD
The examples use the Chinook Dashboard from Chapter 21. You'll build a test project that verifies the dashboard's functionality at every level.
#
22.2 Unit Testing the Server Side
Unit tests verify individual components in isolation. For htmx applications, this means testing services, view models, and helper methods without involving HTTP requests or HTML rendering.
#
22.2.1 Testing Services
Services contain business logic and data access. Test them using an in-memory database to avoid external dependencies.
#
Test Project Setup
Create a test project alongside your main project:
dotnet new xunit -n ChinookDashboard.Tests
dotnet add ChinookDashboard.Tests reference ChinookDashboard
ChinookDashboard.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="AngleSharp" Version="1.1.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.40.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ChinookDashboard\ChinookDashboard.csproj" />
</ItemGroup>
</Project>
#
Testing with In-Memory Database
Create a base class for service tests that sets up an in-memory SQLite database:
Unit/ServiceTestBase.cs
using ChinookDashboard.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ChinookDashboard.Tests.Unit;
public abstract class ServiceTestBase : IDisposable
{
private readonly SqliteConnection _connection;
protected readonly ChinookContext Context;
protected ServiceTestBase()
{
// Create and open a connection that stays open for the test duration
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ChinookContext>()
.UseSqlite(_connection)
.Options;
Context = new ChinookContext(options);
Context.Database.EnsureCreated();
}
public void Dispose()
{
Context.Dispose();
_connection.Dispose();
}
}
#
Complete ArtistServiceTests
Unit/Services/ArtistServiceTests.cs
using ChinookDashboard.Data.Entities;
using ChinookDashboard.Services;
namespace ChinookDashboard.Tests.Unit.Services;
public class ArtistServiceTests : ServiceTestBase
{
private readonly ArtistService _service;
public ArtistServiceTests()
{
_service = new ArtistService(Context);
SeedTestArtists();
}
private void SeedTestArtists()
{
Context.Artists.AddRange(
new Artist { Id = 1, Name = "AC/DC" },
new Artist { Id = 2, Name = "Accept" },
new Artist { Id = 3, Name = "Aerosmith" },
new Artist { Id = 4, Name = "Led Zeppelin" },
new Artist { Id = 5, Name = "Metallica" }
);
Context.SaveChanges();
}
[Fact]
public async Task GetAllAsync_ReturnsAllArtists_OrderedByName()
{
// Act
var result = await _service.GetAllAsync();
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("AC/DC", result[0].Name);
Assert.Equal("Accept", result[1].Name);
}
[Fact]
public async Task SearchAsync_WithMatchingTerm_ReturnsFilteredResults()
{
// Act
var result = await _service.SearchAsync("ac");
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(result, a => a.Name == "AC/DC");
Assert.Contains(result, a => a.Name == "Accept");
}
[Fact]
public async Task SearchAsync_WithNoMatch_ReturnsEmptyList()
{
// Act
var result = await _service.SearchAsync("xyz");
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SearchAsync_WithNullOrEmpty_ReturnsAllArtists()
{
// Act
var resultNull = await _service.SearchAsync(null);
var resultEmpty = await _service.SearchAsync("");
// Assert
Assert.Equal(5, resultNull.Count);
Assert.Equal(5, resultEmpty.Count);
}
[Fact]
public async Task SearchAsync_IsCaseInsensitive()
{
// Act
var resultLower = await _service.SearchAsync("ac/dc");
var resultUpper = await _service.SearchAsync("AC/DC");
var resultMixed = await _service.SearchAsync("Ac/Dc");
// Assert
Assert.Single(resultLower);
Assert.Single(resultUpper);
Assert.Single(resultMixed);
}
[Fact]
public async Task GetByIdAsync_WithValidId_ReturnsArtist()
{
// Act
var result = await _service.GetByIdAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("AC/DC", result.Name);
}
[Fact]
public async Task GetByIdAsync_WithInvalidId_ReturnsNull()
{
// Act
var result = await _service.GetByIdAsync(999);
// Assert
Assert.Null(result);
}
[Fact]
public async Task CreateAsync_AddsNewArtist_ReturnsCreatedArtist()
{
// Act
var result = await _service.CreateAsync("New Artist");
// Assert
Assert.NotNull(result);
Assert.True(result.Id > 0);
Assert.Equal("New Artist", result.Name);
// Verify persisted
var persisted = await Context.Artists.FindAsync(result.Id);
Assert.NotNull(persisted);
}
[Fact]
public async Task CreateAsync_WithWhitespace_TrimsName()
{
// Act
var result = await _service.CreateAsync(" Spaced Name ");
// Assert
Assert.Equal("Spaced Name", result?.Name);
}
[Fact]
public async Task UpdateAsync_WithValidId_UpdatesAndReturnsArtist()
{
// Act
var result = await _service.UpdateAsync(1, "AC/DC Updated");
// Assert
Assert.NotNull(result);
Assert.Equal("AC/DC Updated", result.Name);
// Verify persisted
var persisted = await Context.Artists.FindAsync(1);
Assert.Equal("AC/DC Updated", persisted?.Name);
}
[Fact]
public async Task UpdateAsync_WithInvalidId_ReturnsNull()
{
// Act
var result = await _service.UpdateAsync(999, "Does Not Exist");
// Assert
Assert.Null(result);
}
[Fact]
public async Task DeleteAsync_WithValidId_RemovesArtist_ReturnsTrue()
{
// Act
var result = await _service.DeleteAsync(1);
// Assert
Assert.True(result);
// Verify removed
var deleted = await Context.Artists.FindAsync(1);
Assert.Null(deleted);
}
[Fact]
public async Task DeleteAsync_WithInvalidId_ReturnsFalse()
{
// Act
var result = await _service.DeleteAsync(999);
// Assert
Assert.False(result);
}
[Fact]
public async Task GetCountAsync_ReturnsCorrectCount()
{
// Act
var result = await _service.GetCountAsync();
// Assert
Assert.Equal(5, result);
}
[Fact]
public async Task GetCountAsync_AfterDelete_ReturnsUpdatedCount()
{
// Arrange
await _service.DeleteAsync(1);
// Act
var result = await _service.GetCountAsync();
// Assert
Assert.Equal(4, result);
}
}
#
22.2.2 Testing View Models
View models often contain computed properties and transformation logic. Test these independently of the data layer.
#
Testing Computed Properties
Unit/Models/TrackSummaryTests.cs
using ChinookDashboard.Models;
namespace ChinookDashboard.Tests.Unit.Models;
public class TrackSummaryTests
{
[Theory]
[InlineData(0, "0:00")]
[InlineData(1000, "0:01")]
[InlineData(60000, "1:00")]
[InlineData(61000, "1:01")]
[InlineData(3599000, "59:59")]
[InlineData(3600000, "1:00:00")]
[InlineData(3661000, "1:01:01")]
[InlineData(36000000, "10:00:00")]
public void Duration_FormatsCorrectly(int milliseconds, string expected)
{
// Arrange
var track = new TrackSummary { Milliseconds = milliseconds };
// Act
var result = track.Duration;
// Assert
Assert.Equal(expected, result);
}
}
#
Testing PaginatedList
Unit/Models/PaginatedListTests.cs
using ChinookDashboard.Models;
namespace ChinookDashboard.Tests.Unit.Models;
public class PaginatedListTests
{
[Fact]
public void Constructor_SetsPropertiesCorrectly()
{
// Arrange
var items = new List<string> { "a", "b", "c" };
// Act
var result = new PaginatedList<string>(items, totalCount: 100, pageNumber: 3, pageSize: 10);
// Assert
Assert.Equal(3, result.Items.Count);
Assert.Equal(100, result.TotalCount);
Assert.Equal(3, result.PageNumber);
Assert.Equal(10, result.PageSize);
}
[Fact]
public void TotalPages_CalculatesCorrectly()
{
// Arrange & Act
var exact = new PaginatedList<int>(new List<int>(), 100, 1, 10);
var remainder = new PaginatedList<int>(new List<int>(), 95, 1, 10);
var lessThanPage = new PaginatedList<int>(new List<int>(), 5, 1, 10);
var empty = new PaginatedList<int>(new List<int>(), 0, 1, 10);
// Assert
Assert.Equal(10, exact.TotalPages);
Assert.Equal(10, remainder.TotalPages);
Assert.Equal(1, lessThanPage.TotalPages);
Assert.Equal(0, empty.TotalPages);
}
[Fact]
public void HasPreviousPage_ReturnsTrueWhenNotOnFirstPage()
{
// Arrange & Act
var firstPage = new PaginatedList<int>(new List<int>(), 100, 1, 10);
var secondPage = new PaginatedList<int>(new List<int>(), 100, 2, 10);
var lastPage = new PaginatedList<int>(new List<int>(), 100, 10, 10);
// Assert
Assert.False(firstPage.HasPreviousPage);
Assert.True(secondPage.HasPreviousPage);
Assert.True(lastPage.HasPreviousPage);
}
[Fact]
public void HasNextPage_ReturnsTrueWhenNotOnLastPage()
{
// Arrange & Act
var firstPage = new PaginatedList<int>(new List<int>(), 100, 1, 10);
var middlePage = new PaginatedList<int>(new List<int>(), 100, 5, 10);
var lastPage = new PaginatedList<int>(new List<int>(), 100, 10, 10);
// Assert
Assert.True(firstPage.HasNextPage);
Assert.True(middlePage.HasNextPage);
Assert.False(lastPage.HasNextPage);
}
[Fact]
public void StartItem_CalculatesCorrectly()
{
// Arrange & Act
var firstPage = new PaginatedList<int>(new List<int>(), 100, 1, 10);
var secondPage = new PaginatedList<int>(new List<int>(), 100, 2, 10);
var empty = new PaginatedList<int>(new List<int>(), 0, 1, 10);
// Assert
Assert.Equal(1, firstPage.StartItem);
Assert.Equal(11, secondPage.StartItem);
Assert.Equal(0, empty.StartItem);
}
[Fact]
public void EndItem_CalculatesCorrectly()
{
// Arrange & Act
var fullPage = new PaginatedList<int>(Enumerable.Range(1, 10).ToList(), 100, 1, 10);
var partialPage = new PaginatedList<int>(Enumerable.Range(1, 5).ToList(), 95, 10, 10);
var empty = new PaginatedList<int>(new List<int>(), 0, 1, 10);
// Assert
Assert.Equal(10, fullPage.EndItem);
Assert.Equal(95, partialPage.EndItem);
Assert.Equal(0, empty.EndItem);
}
}
#
22.2.3 Testing Helper Methods
Test extension methods and helpers that support htmx functionality.
Unit/Helpers/HtmxExtensionTests.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace ChinookDashboard.Tests.Unit.Helpers;
public class HtmxExtensionTests
{
[Fact]
public void IsHtmxRequest_WithHeader_ReturnsTrue()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Headers["HX-Request"] = "true";
// Act
var result = context.Request.IsHtmxRequest();
// Assert
Assert.True(result);
}
[Fact]
public void IsHtmxRequest_WithoutHeader_ReturnsFalse()
{
// Arrange
var context = new DefaultHttpContext();
// Act
var result = context.Request.IsHtmxRequest();
// Assert
Assert.False(result);
}
[Fact]
public void GetHtmxTarget_ReturnsTargetValue()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Headers["HX-Target"] = "artist-list";
// Act
var result = context.Request.GetHtmxTarget();
// Assert
Assert.Equal("artist-list", result);
}
[Fact]
public void GetHtmxTarget_WithoutHeader_ReturnsNull()
{
// Arrange
var context = new DefaultHttpContext();
// Act
var result = context.Request.GetHtmxTarget();
// Assert
Assert.Null(result);
}
[Fact]
public void GetHtmxTrigger_ReturnsTriggerValue()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Headers["HX-Trigger"] = "search-input";
// Act
var result = context.Request.GetHtmxTrigger();
// Assert
Assert.Equal("search-input", result);
}
}
// Extension methods being tested (should be in main project)
public static class HtmxRequestExtensions
{
public static bool IsHtmxRequest(this HttpRequest request)
{
return request.Headers.ContainsKey("HX-Request");
}
public static string? GetHtmxTarget(this HttpRequest request)
{
return request.Headers.TryGetValue("HX-Target", out var value)
? value.ToString()
: null;
}
public static string? GetHtmxTrigger(this HttpRequest request)
{
return request.Headers.TryGetValue("HX-Trigger", out var value)
? value.ToString()
: null;
}
}
Unit/Helpers/ToastHelperTests.cs
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace ChinookDashboard.Tests.Unit.Helpers;
public class ToastHelperTests
{
[Fact]
public void CreateToastTrigger_ReturnsCorrectJson()
{
// Act
var result = ToastHelper.CreateToastTrigger("Artist created successfully", "success");
// Assert
var parsed = JsonDocument.Parse(result);
var showToast = parsed.RootElement.GetProperty("showToast");
Assert.Equal("Artist created successfully", showToast.GetProperty("message").GetString());
Assert.Equal("success", showToast.GetProperty("type").GetString());
}
[Fact]
public void CreateToastTrigger_WithDefaultType_UsesSuccess()
{
// Act
var result = ToastHelper.CreateToastTrigger("Done");
// Assert
var parsed = JsonDocument.Parse(result);
var showToast = parsed.RootElement.GetProperty("showToast");
Assert.Equal("success", showToast.GetProperty("type").GetString());
}
[Fact]
public void AddToastHeader_SetsHxTriggerHeader()
{
// Arrange
var context = new DefaultHttpContext();
// Act
ToastHelper.AddToastHeader(context.Response, "Test message", "info");
// Assert
Assert.True(context.Response.Headers.ContainsKey("HX-Trigger"));
var headerValue = context.Response.Headers["HX-Trigger"].ToString();
Assert.Contains("showToast", headerValue);
Assert.Contains("Test message", headerValue);
}
}
// Helper class being tested (should be in main project)
public static class ToastHelper
{
public static string CreateToastTrigger(string message, string type = "success")
{
return JsonSerializer.Serialize(new
{
showToast = new { message, type }
});
}
public static void AddToastHeader(HttpResponse response, string message, string type = "success")
{
response.Headers["HX-Trigger"] = CreateToastTrigger(message, type);
}
}
#
22.3 Integration Testing Razor Page Handlers
Integration tests verify that your Razor Page handlers return correct HTML with proper htmx attributes. These tests use WebApplicationFactory to host the application in-memory and send real HTTP requests.
#
22.3.1 Setting Up Test Infrastructure
#
ChinookTestFactory
Create a custom factory that configures the application for testing:
Integration/Fixtures/ChinookTestFactory.cs
using ChinookDashboard.Data;
using ChinookDashboard.Data.Entities;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ChinookDashboard.Tests.Integration.Fixtures;
public class ChinookTestFactory : WebApplicationFactory<Program>
{
private SqliteConnection? _connection;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ChinookContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Create persistent SQLite connection for tests
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
// Add test DbContext
services.AddDbContext<ChinookContext>(options =>
{
options.UseSqlite(_connection);
});
// Build service provider and initialize database
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ChinookContext>();
db.Database.EnsureCreated();
SeedTestData(db);
});
builder.UseEnvironment("Testing");
}
private static void SeedTestData(ChinookContext context)
{
// Add test artists
var artists = new[]
{
new Artist { Id = 1, Name = "AC/DC" },
new Artist { Id = 2, Name = "Accept" },
new Artist { Id = 3, Name = "Aerosmith" },
new Artist { Id = 4, Name = "Led Zeppelin" },
new Artist { Id = 5, Name = "Metallica" },
new Artist { Id = 6, Name = "Iron Maiden" },
new Artist { Id = 7, Name = "Black Sabbath" },
new Artist { Id = 8, Name = "Deep Purple" },
new Artist { Id = 9, Name = "Judas Priest" },
new Artist { Id = 10, Name = "Ozzy Osbourne" }
};
context.Artists.AddRange(artists);
// Add test albums
var albums = new[]
{
new Album { Id = 1, Title = "Back in Black", ArtistId = 1 },
new Album { Id = 2, Title = "Highway to Hell", ArtistId = 1 },
new Album { Id = 3, Title = "Restless and Wild", ArtistId = 2 },
new Album { Id = 4, Title = "Get a Grip", ArtistId = 3 },
new Album { Id = 5, Title = "Led Zeppelin IV", ArtistId = 4 }
};
context.Albums.AddRange(albums);
// Add test genres
var genres = new[]
{
new Genre { Id = 1, Name = "Rock" },
new Genre { Id = 2, Name = "Metal" },
new Genre { Id = 3, Name = "Blues" }
};
context.Genres.AddRange(genres);
// Add test tracks
var tracks = new[]
{
new Track { Id = 1, Name = "Back in Black", AlbumId = 1, GenreId = 1, Milliseconds = 255000, UnitPrice = 0.99m },
new Track { Id = 2, Name = "Hells Bells", AlbumId = 1, GenreId = 1, Milliseconds = 312000, UnitPrice = 0.99m },
new Track { Id = 3, Name = "Highway to Hell", AlbumId = 2, GenreId = 1, Milliseconds = 208000, UnitPrice = 0.99m },
new Track { Id = 4, Name = "Stairway to Heaven", AlbumId = 5, GenreId = 1, Milliseconds = 482000, UnitPrice = 0.99m },
new Track { Id = 5, Name = "Black Dog", AlbumId = 5, GenreId = 1, Milliseconds = 226000, UnitPrice = 0.99m }
};
context.Tracks.AddRange(tracks);
context.SaveChanges();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_connection?.Dispose();
}
}
}
#
Integration Test Base Class
Integration/IntegrationTestBase.cs
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration;
public abstract class IntegrationTestBase : IClassFixture<ChinookTestFactory>
{
protected readonly HttpClient Client;
protected readonly ChinookTestFactory Factory;
protected IntegrationTestBase(ChinookTestFactory factory)
{
Factory = factory;
Client = factory.CreateClient();
}
protected async Task<IHtmlDocument> GetHtmlDocumentAsync(HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
var context = BrowsingContext.New(Configuration.Default);
return await context.OpenAsync(req => req.Content(content)) as IHtmlDocument
?? throw new InvalidOperationException("Failed to parse HTML document");
}
protected async Task<IHtmlDocument> GetPageAsync(string url)
{
var response = await Client.GetAsync(url);
response.EnsureSuccessStatusCode();
return await GetHtmlDocumentAsync(response);
}
}
#
htmx Request Helper Extensions
Integration/Common/HttpClientHtmxExtensions.cs
namespace ChinookDashboard.Tests.Integration.Common;
public static class HttpClientHtmxExtensions
{
/// <summary>
/// Creates an HTTP request configured as an htmx request with appropriate headers.
/// </summary>
public static HttpRequestMessage CreateHtmxRequest(
this HttpClient client,
HttpMethod method,
string url,
string? target = null,
string? trigger = null,
string? currentUrl = null)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Add("HX-Request", "true");
if (target != null)
request.Headers.Add("HX-Target", target);
if (trigger != null)
request.Headers.Add("HX-Trigger", trigger);
if (currentUrl != null)
request.Headers.Add("HX-Current-URL", currentUrl);
return request;
}
/// <summary>
/// Sends a GET request as an htmx request.
/// </summary>
public static async Task<HttpResponseMessage> HtmxGetAsync(
this HttpClient client,
string url,
string? target = null,
string? trigger = null)
{
var request = client.CreateHtmxRequest(HttpMethod.Get, url, target, trigger);
return await client.SendAsync(request);
}
/// <summary>
/// Sends a POST request as an htmx request.
/// </summary>
public static async Task<HttpResponseMessage> HtmxPostAsync(
this HttpClient client,
string url,
HttpContent? content = null,
string? target = null,
string? trigger = null)
{
var request = client.CreateHtmxRequest(HttpMethod.Post, url, target, trigger);
request.Content = content;
return await client.SendAsync(request);
}
/// <summary>
/// Sends a DELETE request as an htmx request.
/// </summary>
public static async Task<HttpResponseMessage> HtmxDeleteAsync(
this HttpClient client,
string url,
string? target = null,
string? trigger = null)
{
var request = client.CreateHtmxRequest(HttpMethod.Delete, url, target, trigger);
return await client.SendAsync(request);
}
}
#
22.3.2 Testing Full Page Requests
Test that full page loads return complete HTML with all required elements.
Integration/Artists/ArtistPageTests.cs
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Artists;
public class ArtistPageTests : IntegrationTestBase
{
public ArtistPageTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task ArtistsIndex_ReturnsSuccessAndCorrectContentType()
{
// Act
var response = await Client.GetAsync("/Artists");
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("text/html; charset=utf-8",
response.Content.Headers.ContentType?.ToString());
}
[Fact]
public async Task ArtistsIndex_ContainsHtmxScript()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var htmxScript = document.QuerySelector("script[src*='htmx']");
Assert.NotNull(htmxScript);
}
[Fact]
public async Task ArtistsIndex_ContainsSearchInput_WithHtmxAttributes()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var searchInput = document.QuerySelector("input[name='search']");
Assert.NotNull(searchInput);
// Verify htmx attributes
Assert.NotNull(searchInput.GetAttribute("hx-get"));
Assert.Contains("handler=List", searchInput.GetAttribute("hx-get"));
Assert.NotNull(searchInput.GetAttribute("hx-target"));
Assert.NotNull(searchInput.GetAttribute("hx-trigger"));
Assert.Contains("keyup", searchInput.GetAttribute("hx-trigger"));
}
[Fact]
public async Task ArtistsIndex_ContainsArtistTable_WithCorrectStructure()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var table = document.QuerySelector("table.artist-table, #artist-table");
Assert.NotNull(table);
var rows = document.QuerySelectorAll("tr[id^='artist-row-']");
Assert.True(rows.Length > 0);
}
[Fact]
public async Task ArtistsIndex_ContainsAddButton_WithHtmxAttributes()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var addButton = document.QuerySelector("#add-artist-btn, button[hx-get*='CreateForm']");
Assert.NotNull(addButton);
Assert.Contains("CreateForm", addButton.GetAttribute("hx-get"));
}
[Fact]
public async Task ArtistsIndex_ContainsModalContainer()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var modalContainer = document.QuerySelector("#modal-container");
Assert.NotNull(modalContainer);
}
[Fact]
public async Task ArtistsIndex_ArtistRows_HaveEditButtons_WithCorrectAttributes()
{
// Act
var document = await GetPageAsync("/Artists");
// Assert
var editButtons = document.QuerySelectorAll("button[hx-get*='handler=Edit']");
Assert.True(editButtons.Length > 0);
var firstEditButton = editButtons[0];
Assert.Contains("hx-target", firstEditButton.Attributes.Select(a => a.Name));
Assert.Contains("hx-swap", firstEditButton.Attributes.Select(a => a.Name));
Assert.Equal("outerHTML", firstEditButton.GetAttribute("hx-swap"));
}
}
#
22.3.3 Testing Partial Responses
Test that htmx requests return partial HTML without layout.
Integration/Artists/ArtistPartialTests.cs
using AngleSharp.Html.Dom;
using ChinookDashboard.Tests.Integration.Common;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Artists;
public class ArtistPartialTests : IntegrationTestBase
{
public ArtistPartialTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task ArtistList_HtmxRequest_ReturnsPartialWithoutLayout()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=List",
target: "artist-list");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
// Should NOT contain layout elements
Assert.DoesNotContain("<!DOCTYPE", content);
Assert.DoesNotContain("<html", content);
Assert.DoesNotContain("<head>", content);
Assert.DoesNotContain("<body>", content);
// Should contain artist list content
Assert.Contains("artist-row", content);
}
[Fact]
public async Task ArtistList_WithSearchTerm_ReturnsFilteredResults()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=List&search=AC",
target: "artist-list");
var document = await GetHtmlDocumentAsync(response);
// Assert
var rows = document.QuerySelectorAll("tr[id^='artist-row-']");
// Should only contain artists matching "AC" (AC/DC, Accept)
Assert.Equal(2, rows.Length);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("AC/DC", content);
Assert.Contains("Accept", content);
Assert.DoesNotContain("Metallica", content);
}
[Fact]
public async Task ArtistList_WithNoMatchingSearch_ReturnsEmptyState()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=List&search=ZZZZZ",
target: "artist-list");
var document = await GetHtmlDocumentAsync(response);
// Assert
var rows = document.QuerySelectorAll("tr[id^='artist-row-']");
Assert.Empty(rows);
// Should contain empty state message
var emptyState = document.QuerySelector(".empty-state, [class*='empty']");
Assert.NotNull(emptyState);
}
[Fact]
public async Task EditForm_ReturnsFormPartial_WithCorrectValues()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Assert
var form = document.QuerySelector("form") as IHtmlFormElement;
Assert.NotNull(form);
Assert.Contains("handler=Update", form.Action ?? form.GetAttribute("hx-post") ?? "");
var nameInput = document.QuerySelector("input[name='name']") as IHtmlInputElement;
Assert.NotNull(nameInput);
Assert.Equal("AC/DC", nameInput.Value);
}
[Fact]
public async Task EditForm_HasCorrectHtmxAttributes()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Assert
var form = document.QuerySelector("form");
Assert.NotNull(form);
// Form should have htmx POST attribute
var hxPost = form.GetAttribute("hx-post");
Assert.NotNull(hxPost);
Assert.Contains("handler=Update", hxPost);
Assert.Contains("id=1", hxPost);
// Form should target the row for swap
var hxTarget = form.GetAttribute("hx-target");
Assert.NotNull(hxTarget);
// Form should use outerHTML swap
var hxSwap = form.GetAttribute("hx-swap");
Assert.Equal("outerHTML", hxSwap);
}
[Fact]
public async Task CreateForm_ReturnsModalContent()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=CreateForm",
target: "modal-container");
var document = await GetHtmlDocumentAsync(response);
// Assert
var form = document.QuerySelector("form");
Assert.NotNull(form);
var hxPost = form.GetAttribute("hx-post");
Assert.NotNull(hxPost);
Assert.Contains("handler=Create", hxPost);
var nameInput = document.QuerySelector("input[name='name']") as IHtmlInputElement;
Assert.NotNull(nameInput);
Assert.True(string.IsNullOrEmpty(nameInput.Value)); // New form should be empty
}
}
#
22.3.4 Testing htmx Response Headers
Test that handlers set correct htmx response headers.
Integration/Artists/ArtistResponseHeaderTests.cs
using System.Text.Json;
using ChinookDashboard.Tests.Integration.Common;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Artists;
public class ArtistResponseHeaderTests : IntegrationTestBase
{
public ArtistResponseHeaderTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task CreateArtist_Success_ReturnsHxTriggerHeader_WithToast()
{
// Arrange
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "New Test Artist")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Create",
formContent,
target: "modal-container");
// Assert
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.Contains("HX-Trigger"));
var triggerValue = response.Headers.GetValues("HX-Trigger").First();
// Parse and verify toast content
var trigger = JsonDocument.Parse(triggerValue);
Assert.True(trigger.RootElement.TryGetProperty("showToast", out var showToast));
Assert.Equal("success", showToast.GetProperty("type").GetString());
Assert.Contains("created", showToast.GetProperty("message").GetString()?.ToLower());
}
[Fact]
public async Task UpdateArtist_Success_ReturnsHxTriggerHeader()
{
// Arrange
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "Updated Artist Name")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Update&id=1",
formContent,
target: "artist-row-1");
// Assert
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.Contains("HX-Trigger"));
}
[Fact]
public async Task DeleteArtist_Success_ReturnsHxTriggerHeader_WithToast()
{
// Act
var response = await Client.HtmxDeleteAsync(
"/Artists?handler=Delete&id=10", // Use ID 10 to not break other tests
target: "artist-row-10");
// Assert
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.Contains("HX-Trigger"));
var triggerValue = response.Headers.GetValues("HX-Trigger").First();
Assert.Contains("showToast", triggerValue);
}
[Fact]
public async Task ArtistList_WithSearch_ReturnsHxPushUrl()
{
// Act
var response = await Client.HtmxGetAsync(
"/Artists?handler=List&search=test",
target: "artist-list");
// Assert
// Check if HX-Push-Url is set for URL state management
if (response.Headers.Contains("HX-Push-Url"))
{
var pushUrl = response.Headers.GetValues("HX-Push-Url").First();
Assert.Contains("search=test", pushUrl);
}
}
[Fact]
public async Task CreateArtist_WithInvalidData_Returns400_NoSuccessToast()
{
// Arrange - empty name should fail validation
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Create",
formContent,
target: "modal-container");
// Assert
// Should return the form with validation errors, not a success toast
var content = await response.Content.ReadAsStringAsync();
// Either returns 400/422 or returns form with error message
if (response.IsSuccessStatusCode)
{
// If successful response, should contain validation error, not success toast
Assert.Contains("validation", content.ToLower());
if (response.Headers.Contains("HX-Trigger"))
{
var trigger = response.Headers.GetValues("HX-Trigger").First();
Assert.DoesNotContain("success", trigger.ToLower());
}
}
}
}
#
22.3.5 Testing OOB Updates
Test that responses include correct OOB update elements.
Integration/Artists/ArtistOobTests.cs
using ChinookDashboard.Tests.Integration.Common;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Artists;
public class ArtistOobTests : IntegrationTestBase
{
public ArtistOobTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task CreateArtist_ReturnsOobUpdate_ForResultCount()
{
// Arrange
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "OOB Test Artist")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Create",
formContent,
target: "modal-container");
var document = await GetHtmlDocumentAsync(response);
// Assert
// Find OOB element for result count
var oobElements = document.QuerySelectorAll("[hx-swap-oob]");
Assert.True(oobElements.Length > 0, "Response should contain OOB elements");
// Check for result count OOB
var resultCountOob = document.QuerySelector("#result-count[hx-swap-oob], [hx-swap-oob][id='result-count']");
// Note: Element may or may not be present depending on implementation
}
[Fact]
public async Task CreateArtist_ReturnsOobUpdate_ForNewRow()
{
// Arrange
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "New Row Test Artist")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Create",
formContent,
target: "modal-container");
var document = await GetHtmlDocumentAsync(response);
// Assert
// Check for new row with OOB to insert into table
var newRowOob = document.QuerySelector("tr[hx-swap-oob*='afterbegin'], tr[hx-swap-oob*='beforeend']");
if (newRowOob != null)
{
var oobValue = newRowOob.GetAttribute("hx-swap-oob");
Assert.Contains("artist-table", oobValue ?? "");
}
}
[Fact]
public async Task DeleteArtist_ReturnsOobUpdate_ForStats()
{
// First, create an artist to delete
var createContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "To Be Deleted")
});
var createResponse = await Client.HtmxPostAsync(
"/Artists?handler=Create",
createContent);
// Get the created artist's ID from response
var createDoc = await GetHtmlDocumentAsync(createResponse);
var newRow = createDoc.QuerySelector("tr[id^='artist-row-']");
var newId = newRow?.Id?.Replace("artist-row-", "") ?? "999";
// Act - Delete the artist
var response = await Client.HtmxDeleteAsync(
$"/Artists?handler=Delete&id={newId}",
target: $"artist-row-{newId}");
var document = await GetHtmlDocumentAsync(response);
// Assert
var oobElements = document.QuerySelectorAll("[hx-swap-oob]");
// Should have OOB updates for stats
var hasStatsOob = oobElements.Any(e =>
e.Id?.Contains("stat") == true ||
e.Id?.Contains("count") == true ||
e.GetAttribute("hx-swap-oob")?.Contains("stat") == true);
// At minimum should have delete marker or OOB updates
Assert.True(oobElements.Length >= 0); // Relaxed assertion - implementation varies
}
[Fact]
public async Task DeleteArtist_ResponseContains_DeleteSwapForRow()
{
// Act
var response = await Client.HtmxDeleteAsync(
"/Artists?handler=Delete&id=9", // Judas Priest
target: "artist-row-9");
var content = await response.Content.ReadAsStringAsync();
// Assert
// Response might include OOB delete or be empty for the row
// Check for either approach
var hasDeleteOob = content.Contains("hx-swap-oob=\"delete\"") ||
content.Contains("hx-swap-oob='delete'");
var isMinimalResponse = string.IsNullOrWhiteSpace(content) ||
content.Length < 50;
// Either approach is valid
Assert.True(hasDeleteOob || isMinimalResponse || response.IsSuccessStatusCode,
"Delete should either return OOB delete marker or minimal/empty response");
}
[Fact]
public async Task OobElements_HaveCorrectSwapValues()
{
// Arrange
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("name", "OOB Swap Test")
});
// Act
var response = await Client.HtmxPostAsync(
"/Artists?handler=Create",
formContent);
var document = await GetHtmlDocumentAsync(response);
// Assert
var oobElements = document.QuerySelectorAll("[hx-swap-oob]");
foreach (var element in oobElements)
{
var oobValue = element.GetAttribute("hx-swap-oob");
Assert.NotNull(oobValue);
// Valid OOB values
var validValues = new[] { "true", "innerHTML", "outerHTML",
"beforebegin", "afterbegin", "beforeend", "afterend", "delete", "none" };
// OOB value should start with a valid swap type or be a selector
var isValidStart = validValues.Any(v => oobValue.StartsWith(v)) ||
oobValue.Contains(":"); // selector syntax like "afterbegin:#target"
Assert.True(isValidStart,
$"OOB value '{oobValue}' should be a valid swap type or selector");
}
}
}
#
HTML Parsing Helpers
Create a helper class for common HTML assertions:
Integration/Common/HtmlAssertions.cs
using AngleSharp.Dom;
namespace ChinookDashboard.Tests.Integration.Common;
public static class HtmlAssertions
{
public static void HasAttribute(IElement element, string attributeName, string? expectedValue = null)
{
var actualValue = element.GetAttribute(attributeName);
Assert.NotNull(actualValue);
if (expectedValue != null)
{
Assert.Equal(expectedValue, actualValue);
}
}
public static void HasHxGet(IElement element, string? expectedUrlPart = null)
{
var hxGet = element.GetAttribute("hx-get");
Assert.NotNull(hxGet);
if (expectedUrlPart != null)
{
Assert.Contains(expectedUrlPart, hxGet);
}
}
public static void HasHxPost(IElement element, string? expectedUrlPart = null)
{
var hxPost = element.GetAttribute("hx-post");
Assert.NotNull(hxPost);
if (expectedUrlPart != null)
{
Assert.Contains(expectedUrlPart, hxPost);
}
}
public static void HasHxTarget(IElement element, string expectedTarget)
{
var hxTarget = element.GetAttribute("hx-target");
Assert.NotNull(hxTarget);
Assert.Equal(expectedTarget, hxTarget);
}
public static void HasHxSwap(IElement element, string expectedSwap)
{
var hxSwap = element.GetAttribute("hx-swap");
Assert.NotNull(hxSwap);
Assert.Equal(expectedSwap, hxSwap);
}
public static void HasHxTrigger(IElement element, string expectedTriggerPart)
{
var hxTrigger = element.GetAttribute("hx-trigger");
Assert.NotNull(hxTrigger);
Assert.Contains(expectedTriggerPart, hxTrigger);
}
public static void IsOobSwap(IElement element, string? expectedOobValue = null)
{
var oobValue = element.GetAttribute("hx-swap-oob");
Assert.NotNull(oobValue);
if (expectedOobValue != null)
{
Assert.Equal(expectedOobValue, oobValue);
}
}
public static void DoesNotContainLayoutElements(string html)
{
Assert.DoesNotContain("<!DOCTYPE", html, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<html", html, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<head>", html, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("</body>", html, StringComparison.OrdinalIgnoreCase);
}
}
#
Running the Tests
Execute tests from the command line:
# Run all tests
dotnet test
# Run with verbose output
dotnet test --logger "console;verbosity=detailed"
# Run specific test class
dotnet test --filter "FullyQualifiedName~ArtistPartialTests"
# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"
The integration tests verify that your Razor Page handlers return correct HTML for both full page and htmx partial requests. They confirm htmx attributes are present and correct, response headers are set properly, and OOB updates target the right elements. This level of testing catches most htmx-related bugs before they reach the browser.
#
22.4 Testing htmx Attributes and HTML Structure
Integration tests verify that HTML responses contain correct htmx attributes. This section provides tools and patterns for parsing HTML and asserting on htmx-specific elements.
#
22.4.1 HTML Parsing Strategies
AngleSharp provides a DOM parser that works like browser JavaScript, letting you query elements with CSS selectors and read attributes.
#
Setting Up AngleSharp
AngleSharp is already in the test project dependencies. Create a helper class for common parsing operations:
Integration/Common/HtmlParsingHelper.cs
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
namespace ChinookDashboard.Tests.Integration.Common;
public static class HtmlParsingHelper
{
private static readonly IBrowsingContext BrowsingContext =
BrowsingContext.New(Configuration.Default);
/// <summary>
/// Parses HTML string into a document.
/// </summary>
public static async Task<IHtmlDocument> ParseHtmlAsync(string html)
{
var document = await BrowsingContext.OpenAsync(req => req.Content(html));
return document as IHtmlDocument
?? throw new InvalidOperationException("Failed to parse HTML");
}
/// <summary>
/// Parses HTML from HttpResponseMessage.
/// </summary>
public static async Task<IHtmlDocument> ParseResponseAsync(HttpResponseMessage response)
{
var html = await response.Content.ReadAsStringAsync();
return await ParseHtmlAsync(html);
}
/// <summary>
/// Finds all elements with any htmx attribute.
/// </summary>
public static IEnumerable<IElement> GetHtmxElements(IHtmlDocument document)
{
return document.QuerySelectorAll("[hx-get], [hx-post], [hx-put], [hx-patch], [hx-delete]");
}
/// <summary>
/// Finds all elements with a specific htmx attribute.
/// </summary>
public static IEnumerable<IElement> GetElementsWithAttribute(
IHtmlDocument document,
string attributeName)
{
return document.QuerySelectorAll($"[{attributeName}]");
}
/// <summary>
/// Finds all OOB swap elements in the document.
/// </summary>
public static IEnumerable<IElement> GetOobElements(IHtmlDocument document)
{
return document.QuerySelectorAll("[hx-swap-oob]");
}
/// <summary>
/// Gets all htmx attributes from an element as a dictionary.
/// </summary>
public static Dictionary<string, string> GetHtmxAttributes(IElement element)
{
var htmxAttributes = new Dictionary<string, string>();
foreach (var attr in element.Attributes)
{
if (attr.Name.StartsWith("hx-") || attr.Name == "_")
{
htmxAttributes[attr.Name] = attr.Value;
}
}
return htmxAttributes;
}
/// <summary>
/// Checks if element has all required htmx attributes for a GET request.
/// </summary>
public static bool IsHtmxGetElement(IElement element)
{
return element.HasAttribute("hx-get");
}
/// <summary>
/// Checks if element has all required htmx attributes for a POST request.
/// </summary>
public static bool IsHtmxPostElement(IElement element)
{
return element.HasAttribute("hx-post");
}
/// <summary>
/// Extracts handler name from htmx URL attribute.
/// </summary>
public static string? GetHandlerFromUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return null;
var match = System.Text.RegularExpressions.Regex.Match(
url, @"handler=(\w+)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : null;
}
/// <summary>
/// Extracts query parameters from htmx URL.
/// </summary>
public static Dictionary<string, string> GetUrlParameters(string? url)
{
var parameters = new Dictionary<string, string>();
if (string.IsNullOrEmpty(url)) return parameters;
var queryStart = url.IndexOf('?');
if (queryStart < 0) return parameters;
var query = url[(queryStart + 1)..];
var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var pair in pairs)
{
var parts = pair.Split('=', 2);
if (parts.Length == 2)
{
parameters[parts[0]] = Uri.UnescapeDataString(parts[1]);
}
}
return parameters;
}
}
#
22.4.2 Creating Custom htmx Assertions
Create a dedicated assertions class with clear error messages that show actual vs expected values.
Integration/Common/HtmxAssertions.cs
using AngleSharp.Dom;
using Xunit.Sdk;
namespace ChinookDashboard.Tests.Integration.Common;
/// <summary>
/// Custom assertions for verifying htmx attributes on HTML elements.
/// </summary>
public static class HtmxAssertions
{
/// <summary>
/// Asserts that an element has an hx-get attribute with the expected URL or URL part.
/// </summary>
public static void HasHxGet(IElement element, string? expectedUrlPart = null)
{
var actual = element.GetAttribute("hx-get");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-get' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedUrlPart != null && !actual.Contains(expectedUrlPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-get' to contain '{expectedUrlPart}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-post attribute with the expected URL or URL part.
/// </summary>
public static void HasHxPost(IElement element, string? expectedUrlPart = null)
{
var actual = element.GetAttribute("hx-post");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-post' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedUrlPart != null && !actual.Contains(expectedUrlPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-post' to contain '{expectedUrlPart}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-delete attribute with the expected URL or URL part.
/// </summary>
public static void HasHxDelete(IElement element, string? expectedUrlPart = null)
{
var actual = element.GetAttribute("hx-delete");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-delete' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedUrlPart != null && !actual.Contains(expectedUrlPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-delete' to contain '{expectedUrlPart}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-target attribute with the expected value.
/// </summary>
public static void HasHxTarget(IElement element, string expectedTarget)
{
var actual = element.GetAttribute("hx-target");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-target' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Equals(expectedTarget, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-target' to be '{expectedTarget}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-target attribute containing the expected value.
/// Useful for relative selectors like "closest tr" or "find .content".
/// </summary>
public static void HasHxTargetContaining(IElement element, string expectedPart)
{
var actual = element.GetAttribute("hx-target");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-target' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Contains(expectedPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-target' to contain '{expectedPart}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-swap attribute with the expected value.
/// </summary>
public static void HasHxSwap(IElement element, string expectedSwap)
{
var actual = element.GetAttribute("hx-swap");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-swap' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
// Handle swap with modifiers (e.g., "innerHTML swap:1s")
var actualBase = actual.Split(' ')[0];
if (!actualBase.Equals(expectedSwap, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-swap' to be '{expectedSwap}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-trigger attribute containing the expected trigger.
/// </summary>
public static void HasHxTrigger(IElement element, string expectedTriggerPart)
{
var actual = element.GetAttribute("hx-trigger");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-trigger' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Contains(expectedTriggerPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-trigger' to contain '{expectedTriggerPart}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-trigger with a specific modifier.
/// </summary>
public static void HasHxTriggerWithModifier(IElement element, string trigger, string modifier)
{
var actual = element.GetAttribute("hx-trigger");
if (actual == null)
{
throw new XunitException(
$"Expected element to have 'hx-trigger' attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Contains(trigger, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-trigger' to contain trigger '{trigger}'.\n" +
$"Actual: '{actual}'");
}
if (!actual.Contains(modifier, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-trigger' to contain modifier '{modifier}'.\n" +
$"Actual: '{actual}'");
}
}
/// <summary>
/// Asserts that an element has an hx-include attribute with the expected selector.
/// </summary>
public static void HasHxInclude(IElement element, string expectedSelector)
{
var actual = element.GetAttribute("hx-include");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-include' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Contains(expectedSelector, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-include' to contain '{expectedSelector}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-indicator attribute with the expected selector.
/// </summary>
public static void HasHxIndicator(IElement element, string expectedSelector)
{
var actual = element.GetAttribute("hx-indicator");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-indicator' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (!actual.Contains(expectedSelector, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-indicator' to contain '{expectedSelector}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-swap-oob attribute.
/// </summary>
public static void IsOobSwap(IElement element, string? expectedValue = null)
{
var actual = element.GetAttribute("hx-swap-oob");
if (actual == null)
{
throw new XunitException(
$"Expected element <{element.TagName.ToLower()}> to have 'hx-swap-oob' attribute, but it was not found.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedValue != null && !actual.Equals(expectedValue, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-swap-oob' to be '{expectedValue}'.\n" +
$"Actual: '{actual}'\n" +
$"Element: {GetElementDescription(element)}");
}
}
/// <summary>
/// Asserts that an element has an hx-confirm attribute.
/// </summary>
public static void HasHxConfirm(IElement element, string? expectedMessage = null)
{
var actual = element.GetAttribute("hx-confirm");
if (actual == null)
{
throw new XunitException(
$"Expected element to have 'hx-confirm' attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedMessage != null && !actual.Contains(expectedMessage, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-confirm' to contain '{expectedMessage}'.\n" +
$"Actual: '{actual}'");
}
}
/// <summary>
/// Asserts that an element has an hx-push-url attribute.
/// </summary>
public static void HasHxPushUrl(IElement element, string? expectedValue = null)
{
var actual = element.GetAttribute("hx-push-url");
if (actual == null)
{
throw new XunitException(
$"Expected element to have 'hx-push-url' attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedValue != null && !actual.Equals(expectedValue, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected 'hx-push-url' to be '{expectedValue}'.\n" +
$"Actual: '{actual}'");
}
}
/// <summary>
/// Asserts that an element has a Hyperscript attribute (_).
/// </summary>
public static void HasHyperscript(IElement element, string? expectedContentPart = null)
{
var actual = element.GetAttribute("_");
if (actual == null)
{
throw new XunitException(
$"Expected element to have '_' (Hyperscript) attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
if (expectedContentPart != null && !actual.Contains(expectedContentPart, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected Hyperscript to contain '{expectedContentPart}'.\n" +
$"Actual: '{actual}'");
}
}
/// <summary>
/// Asserts that URL parameters in hx-get/hx-post contain expected values.
/// </summary>
public static void UrlContainsParameter(IElement element, string paramName, string expectedValue)
{
var url = element.GetAttribute("hx-get") ??
element.GetAttribute("hx-post") ??
element.GetAttribute("hx-delete");
if (url == null)
{
throw new XunitException(
$"Expected element to have hx-get, hx-post, or hx-delete attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
var parameters = HtmlParsingHelper.GetUrlParameters(url);
if (!parameters.TryGetValue(paramName, out var actual))
{
throw new XunitException(
$"Expected URL to contain parameter '{paramName}'.\n" +
$"URL: '{url}'\n" +
$"Available parameters: {string.Join(", ", parameters.Keys)}");
}
if (!actual.Equals(expectedValue, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected parameter '{paramName}' to be '{expectedValue}'.\n" +
$"Actual: '{actual}'\n" +
$"URL: '{url}'");
}
}
/// <summary>
/// Asserts that the element points to the expected handler.
/// </summary>
public static void TargetsHandler(IElement element, string expectedHandler)
{
var url = element.GetAttribute("hx-get") ??
element.GetAttribute("hx-post") ??
element.GetAttribute("hx-delete") ??
element.GetAttribute("hx-put");
if (url == null)
{
throw new XunitException(
$"Expected element to have an htmx request attribute.\n" +
$"Element: {GetElementDescription(element)}");
}
var handler = HtmlParsingHelper.GetHandlerFromUrl(url);
if (handler == null || !handler.Equals(expectedHandler, StringComparison.OrdinalIgnoreCase))
{
throw new XunitException(
$"Expected element to target handler '{expectedHandler}'.\n" +
$"Actual handler: '{handler ?? "(none)"}'\n" +
$"URL: '{url}'");
}
}
private static string GetElementDescription(IElement element)
{
var id = element.Id;
var classes = element.ClassName;
var tag = element.TagName.ToLower();
var description = $"<{tag}";
if (!string.IsNullOrEmpty(id)) description += $" id=\"{id}\"";
if (!string.IsNullOrEmpty(classes)) description += $" class=\"{classes}\"";
description += ">";
return description;
}
}
#
22.4.3 Testing Attribute Correctness
With parsing helpers and assertions in place, write tests that verify htmx attributes are correct.
Integration/Tracks/TrackRowAttributeTests.cs
using ChinookDashboard.Tests.Integration.Common;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Tracks;
public class TrackRowAttributeTests : IntegrationTestBase
{
public TrackRowAttributeTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task TrackRow_EditButton_HasCorrectHxGet()
{
// Arrange
var document = await GetPageAsync("/Tracks");
// Act
var editButton = document.QuerySelector("button[hx-get*='handler=Edit']");
// Assert
Assert.NotNull(editButton);
HtmxAssertions.HasHxGet(editButton, "handler=Edit");
HtmxAssertions.TargetsHandler(editButton, "Edit");
}
[Fact]
public async Task TrackRow_EditButton_HasCorrectTarget()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var editButton = document.QuerySelector("#track-row-1 button[hx-get*='Edit']");
// Assert
Assert.NotNull(editButton);
// Should target the parent row or use closest
var target = editButton.GetAttribute("hx-target");
Assert.NotNull(target);
// Target should be either the specific row ID or a relative selector
Assert.True(
target.Contains("track-row-1") ||
target.Contains("closest") ||
target == "this",
$"Expected target to reference the row, got: {target}");
}
[Fact]
public async Task TrackRow_EditButton_UsesOuterHtmlSwap()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var editButton = document.QuerySelector("button[hx-get*='handler=Edit']");
// Assert
Assert.NotNull(editButton);
HtmxAssertions.HasHxSwap(editButton, "outerHTML");
}
[Fact]
public async Task TrackRow_EditButton_IncludesTrackId()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var editButton = document.QuerySelector("#track-row-1 button[hx-get*='Edit']");
// Assert
Assert.NotNull(editButton);
HtmxAssertions.UrlContainsParameter(editButton, "id", "1");
}
[Fact]
public async Task TrackRow_DeleteButton_HasConfirmation()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var deleteButton = document.QuerySelector("button[hx-delete], button[hx-get*='Delete']");
// Assert
if (deleteButton != null)
{
// Delete should have confirmation
HtmxAssertions.HasHxConfirm(deleteButton);
}
}
[Fact]
public async Task TrackList_HasLoadingIndicator()
{
// Arrange
var document = await GetPageAsync("/Tracks");
// Act - Find elements with indicators
var elementsWithIndicator = document.QuerySelectorAll("[hx-indicator]");
// Assert - At least some elements should have indicators
Assert.True(elementsWithIndicator.Length > 0 ||
document.QuerySelector(".htmx-indicator, #loading-spinner") != null,
"Page should have loading indicators configured");
}
[Fact]
public async Task SearchInput_HasCorrectTriggerWithDelay()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var searchInput = document.QuerySelector("input[name='search'][hx-get]");
// Assert
if (searchInput != null)
{
HtmxAssertions.HasHxTrigger(searchInput, "keyup");
HtmxAssertions.HasHxTriggerWithModifier(searchInput, "keyup", "delay:");
}
}
[Fact]
public async Task SearchInput_HasChangedModifier()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var searchInput = document.QuerySelector("input[name='search'][hx-get]");
// Assert
if (searchInput != null)
{
var trigger = searchInput.GetAttribute("hx-trigger");
Assert.NotNull(trigger);
Assert.Contains("changed", trigger);
}
}
[Fact]
public async Task AllHtmxElements_HaveRequiredAttributes()
{
// Arrange
var document = await GetPageAsync("/Tracks");
var htmxElements = HtmlParsingHelper.GetHtmxElements(document);
// Assert - Every htmx element should have a target (explicit or implicit)
foreach (var element in htmxElements)
{
var attrs = HtmlParsingHelper.GetHtmxAttributes(element);
// Elements should have either explicit target or be inside a targetable container
var hasTarget = attrs.ContainsKey("hx-target") ||
element.Closest("[id]") != null;
Assert.True(hasTarget,
$"Element {element.TagName}#{element.Id} should have target context");
}
}
}
#
22.4.4 Testing Form Structure
Forms need correct field names, anti-forgery tokens, and htmx attributes.
Integration/Artists/ArtistEditFormTests.cs
using AngleSharp.Html.Dom;
using ChinookDashboard.Tests.Integration.Common;
using ChinookDashboard.Tests.Integration.Fixtures;
namespace ChinookDashboard.Tests.Integration.Artists;
public class ArtistEditFormTests : IntegrationTestBase
{
public ArtistEditFormTests(ChinookTestFactory factory) : base(factory) { }
[Fact]
public async Task EditForm_ContainsAntiForgeryToken()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var tokenInput = document.QuerySelector(
"input[name='__RequestVerificationToken']");
// Assert
Assert.NotNull(tokenInput);
var tokenValue = (tokenInput as IHtmlInputElement)?.Value;
Assert.False(string.IsNullOrEmpty(tokenValue),
"Anti-forgery token should have a value");
}
[Fact]
public async Task EditForm_HasCorrectFieldNames()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var nameInput = document.QuerySelector("input[name='name']") as IHtmlInputElement;
// Assert
Assert.NotNull(nameInput);
Assert.Equal("AC/DC", nameInput.Value);
}
[Fact]
public async Task EditForm_HasHiddenIdField_OrIdInUrl()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var form = document.QuerySelector("form");
var hiddenId = document.QuerySelector("input[name='id'][type='hidden']");
var hxPost = form?.GetAttribute("hx-post");
// Assert - ID should be in either hidden field or URL
var hasHiddenId = hiddenId != null;
var hasIdInUrl = hxPost?.Contains("id=1") == true;
Assert.True(hasHiddenId || hasIdInUrl,
"Form should include artist ID either as hidden field or in URL");
}
[Fact]
public async Task EditForm_HasCorrectHxPost()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var form = document.QuerySelector("form");
// Assert
Assert.NotNull(form);
HtmxAssertions.HasHxPost(form, "handler=Update");
}
[Fact]
public async Task EditForm_TargetsCorrectRow()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var form = document.QuerySelector("form");
// Assert
Assert.NotNull(form);
var target = form.GetAttribute("hx-target");
Assert.NotNull(target);
// Should target either specific ID or use closest
Assert.True(
target.Contains("artist-row-1") ||
target.Contains("artist-row") ||
target.Contains("closest"),
$"Form target should reference the artist row, got: {target}");
}
[Fact]
public async Task EditForm_HasSaveButton()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var submitButton = document.QuerySelector(
"button[type='submit'], input[type='submit']");
// Assert
Assert.NotNull(submitButton);
}
[Fact]
public async Task EditForm_HasCancelButton_WithCorrectBehavior()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var cancelButton = document.QuerySelector(
"button[hx-get*='Cancel'], button[type='button'][hx-get]");
// Assert
if (cancelButton != null)
{
HtmxAssertions.HasHxGet(cancelButton, "Cancel");
}
else
{
// Alternative: Hyperscript cancel
var hyperscriptCancel = document.QuerySelector("button[_*='cancel'], button[_*='Cancel']");
Assert.NotNull(hyperscriptCancel);
}
}
[Fact]
public async Task EditForm_UsesOuterHtmlSwap()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=Edit&id=1",
target: "artist-row-1");
var document = await GetHtmlDocumentAsync(response);
// Act
var form = document.QuerySelector("form");
// Assert
Assert.NotNull(form);
HtmxAssertions.HasHxSwap(form, "outerHTML");
}
[Fact]
public async Task CreateForm_HasRequiredValidation()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=CreateForm",
target: "modal-container");
var document = await GetHtmlDocumentAsync(response);
// Act
var nameInput = document.QuerySelector("input[name='name']") as IHtmlInputElement;
// Assert
Assert.NotNull(nameInput);
Assert.True(nameInput.IsRequired, "Name input should be required");
}
[Fact]
public async Task CreateForm_HasEmptyFields()
{
// Arrange
var response = await Client.HtmxGetAsync(
"/Artists?handler=CreateForm",
target: "modal-container");
var document = await GetHtmlDocumentAsync(response);
// Act
var nameInput = document.QuerySelector("input[name='name']") as IHtmlInputElement;
// Assert
Assert.NotNull(nameInput);
Assert.True(string.IsNullOrEmpty(nameInput.Value),
"Create form should have empty name field");
}
}
#
22.5 Browser Testing with Playwright
Integration tests verify HTML structure but can't verify that htmx actually updates the DOM correctly. Browser tests with Playwright automate a real browser to test full user interactions.
#
22.5.1 Setting Up Playwright for ASP.NET Core
Playwright requires setup to run the ASP.NET Core application and control a browser.
#
Installing Browsers
After adding the Playwright package, install browsers:
# Run from the test project directory
pwsh bin/Debug/net8.0/playwright.ps1 install
Or add a build target to install automatically:
<!-- Add to test .csproj -->
<Target Name="InstallPlaywright" AfterTargets="Build">
<Exec Command="pwsh $(OutputPath)playwright.ps1 install"
Condition="!Exists('$(USERPROFILE)\.cache\ms-playwright')" />
</Target>
#
PlaywrightFixture
Create a fixture that starts the application and provides the browser:
Browser/Fixtures/PlaywrightFixture.cs
using ChinookDashboard.Tests.Integration.Fixtures;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Playwright;
namespace ChinookDashboard.Tests.Browser.Fixtures;
public class PlaywrightFixture : IAsyncLifetime
{
private IHost? _host;
private IPlaywright? _playwright;
private IBrowser? _browser;
public string BaseUrl { get; private set; } = "";
public IPlaywright Playwright => _playwright ?? throw new InvalidOperationException("Playwright not initialized");
public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser not initialized");
public async Task InitializeAsync()
{
// Start the web application
_host = await StartApplicationAsync();
// Initialize Playwright
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true, // Set to false to see the browser during debugging
SlowMo = 0 // Add delay between actions for debugging (e.g., 100)
});
}
public async Task DisposeAsync()
{
if (_browser != null)
{
await _browser.DisposeAsync();
}
_playwright?.Dispose();
if (_host != null)
{
await _host.StopAsync();
_host.Dispose();
}
}
private async Task<IHost> StartApplicationAsync()
{
// Use the test factory to create a properly configured host
var factory = new ChinookTestFactory();
// Get the server and start it
var host = factory.Services.GetRequiredService<IHost>();
await host.StartAsync();
// Get the server address
var server = host.Services.GetRequiredService<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>();
BaseUrl = addresses?.Addresses.FirstOrDefault() ?? "http://localhost:5000";
return host;
}
public async Task<IBrowserContext> CreateContextAsync()
{
return await Browser.NewContextAsync(new BrowserNewContextOptions
{
BaseURL = BaseUrl,
IgnoreHTTPSErrors = true
});
}
}
#
Alternative: Simpler Fixture Using WebApplicationFactory
For most scenarios, a simpler approach using WebApplicationFactory with a known port works well:
Browser/Fixtures/PlaywrightFixture.cs (Simplified Version)
using ChinookDashboard.Data;
using ChinookDashboard.Data.Entities;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Playwright;
namespace ChinookDashboard.Tests.Browser.Fixtures;
public class PlaywrightFixture : IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private IHost? _host;
private IPlaywright? _playwright;
private IBrowser? _browser;
private SqliteConnection? _connection;
private const int Port = 5555;
public string BaseUrl => $"http://localhost:{Port}";
public IBrowser Browser => _browser ?? throw new InvalidOperationException("Not initialized");
public IPlaywright Playwright => _playwright ?? throw new InvalidOperationException("Not initialized");
public PlaywrightFixture()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseUrls(BaseUrl);
builder.ConfigureServices(services =>
{
// Remove existing DbContext
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ChinookContext>));
if (descriptor != null)
services.Remove(descriptor);
// Use SQLite in-memory
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
services.AddDbContext<ChinookContext>(options =>
options.UseSqlite(_connection));
});
});
}
public async Task InitializeAsync()
{
// Create and start the host
_host = _factory.Services.GetRequiredService<IHost>();
await _host.StartAsync();
// Seed database
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ChinookContext>();
db.Database.EnsureCreated();
SeedTestData(db);
// Initialize Playwright
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
}
public async Task DisposeAsync()
{
if (_browser != null) await _browser.DisposeAsync();
_playwright?.Dispose();
if (_host != null)
{
await _host.StopAsync();
_host.Dispose();
}
_connection?.Dispose();
}
private static void SeedTestData(ChinookContext context)
{
context.Artists.AddRange(
new Artist { Id = 1, Name = "AC/DC" },
new Artist { Id = 2, Name = "Accept" },
new Artist { Id = 3, Name = "Aerosmith" },
new Artist { Id = 4, Name = "Led Zeppelin" },
new Artist { Id = 5, Name = "Metallica" }
);
context.Genres.AddRange(
new Genre { Id = 1, Name = "Rock" },
new Genre { Id = 2, Name = "Metal" }
);
context.SaveChanges();
}
public async Task<IBrowserContext> CreateContextAsync()
{
return await Browser.NewContextAsync(new BrowserNewContextOptions
{
BaseURL = BaseUrl,
IgnoreHTTPSErrors = true
});
}
}
#
PlaywrightTestBase
Create a base class for browser tests:
Browser/PlaywrightTestBase.cs
using ChinookDashboard.Tests.Browser.Fixtures;
using Microsoft.Playwright;
namespace ChinookDashboard.Tests.Browser;
public abstract class PlaywrightTestBase : IClassFixture<PlaywrightFixture>, IAsyncLifetime
{
protected readonly PlaywrightFixture Fixture;
protected IBrowserContext Context { get; private set; } = null!;
protected IPage Page { get; private set; } = null!;
protected string BaseUrl => Fixture.BaseUrl;
protected PlaywrightTestBase(PlaywrightFixture fixture)
{
Fixture = fixture;
}
public async Task InitializeAsync()
{
Context = await Fixture.CreateContextAsync();
Page = await Context.NewPageAsync();
}
public async Task DisposeAsync()
{
await Page.CloseAsync();
await Context.DisposeAsync();
}
/// <summary>
/// Waits for an htmx request to complete.
/// </summary>
protected async Task WaitForHtmxRequestAsync(string urlPart)
{
await Page.WaitForResponseAsync(r =>
r.Url.Contains(urlPart) && r.Status == 200);
}
/// <summary>
/// Waits for an htmx request matching a predicate.
/// </summary>
protected async Task<IResponse> WaitForHtmxResponseAsync(Func<IResponse, bool> predicate)
{
return await Page.WaitForResponseAsync(predicate);
}
/// <summary>
/// Takes a screenshot for debugging.
/// </summary>
protected async Task TakeScreenshotAsync(string name)
{
await Page.ScreenshotAsync(new PageScreenshotOptions
{
Path = $"screenshots/{name}_{DateTime.Now:yyyyMMdd_HHmmss}.png"
});
}
}
#
22.5.2 Testing htmx Interactions
Test that htmx requests work without causing full page reloads.
Browser/Artists/ArtistSearchTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
using Microsoft.Playwright;
namespace ChinookDashboard.Tests.Browser.Artists;
public class ArtistSearchTests : PlaywrightTestBase
{
public ArtistSearchTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task Search_FiltersResults_WithoutPageReload()
{
// Arrange
await Page.GotoAsync("/Artists");
var initialUrl = Page.Url;
// Get initial row count
var initialRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
var initialCount = initialRows.Count;
Assert.True(initialCount > 2, "Should have multiple artists initially");
// Act - Type in search box
var searchInput = await Page.QuerySelectorAsync("input[name='search']");
Assert.NotNull(searchInput);
// Type and wait for htmx response
await searchInput.FillAsync("AC");
await WaitForHtmxRequestAsync("handler=List");
// Small delay for DOM to settle
await Page.WaitForTimeoutAsync(100);
// Assert - Results should be filtered
var filteredRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
Assert.True(filteredRows.Count < initialCount, "Results should be filtered");
Assert.True(filteredRows.Count >= 1, "Should have at least one matching result");
// Verify specific results
var content = await Page.ContentAsync();
Assert.Contains("AC/DC", content);
// Verify no page reload occurred (URL base should be same)
Assert.StartsWith(initialUrl.Split('?')[0], Page.Url.Split('?')[0]);
}
[Fact]
public async Task Search_ShowsNoResults_ForNonMatchingTerm()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act
await Page.FillAsync("input[name='search']", "ZZZZZZ");
await WaitForHtmxRequestAsync("handler=List");
await Page.WaitForTimeoutAsync(100);
// Assert
var rows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
Assert.Empty(rows);
// Should show empty state
var emptyState = await Page.QuerySelectorAsync(".empty-state, [class*='empty'], [class*='no-results']");
Assert.NotNull(emptyState);
}
[Fact]
public async Task Search_ClearsFilter_WhenInputCleared()
{
// Arrange
await Page.GotoAsync("/Artists");
// Filter first
await Page.FillAsync("input[name='search']", "AC");
await WaitForHtmxRequestAsync("handler=List");
var filteredRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
var filteredCount = filteredRows.Count;
// Act - Clear search
await Page.FillAsync("input[name='search']", "");
await WaitForHtmxRequestAsync("handler=List");
await Page.WaitForTimeoutAsync(100);
// Assert - Should show all results again
var allRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
Assert.True(allRows.Count > filteredCount, "Clearing search should show more results");
}
[Fact]
public async Task Search_UpdatesUrlWithSearchParam()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act
await Page.FillAsync("input[name='search']", "Metal");
await WaitForHtmxRequestAsync("handler=List");
await Page.WaitForTimeoutAsync(200);
// Assert - URL should include search parameter (if hx-push-url is used)
// This test documents the expected behavior
var url = Page.Url;
// URL might or might not be updated depending on implementation
// If using hx-push-url="true", it should contain the search param
}
}
#
22.5.3 Testing Dynamic DOM Updates
Test that content swaps work correctly.
Browser/Artists/InlineEditTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Artists;
public class InlineEditTests : PlaywrightTestBase
{
public InlineEditTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task ClickEdit_ShowsEditForm()
{
// Arrange
await Page.GotoAsync("/Artists");
// Find edit button for first artist
var editButton = await Page.QuerySelectorAsync(
"#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
Assert.NotNull(editButton);
// Act - Click edit
await editButton.ClickAsync();
await Page.WaitForSelectorAsync("#artist-row-1 form, form[hx-post*='Update']");
// Assert - Form should appear
var form = await Page.QuerySelectorAsync("#artist-row-1 form, [id^='artist'] form");
Assert.NotNull(form);
// Input should have current value
var input = await Page.QuerySelectorAsync("input[name='name']");
Assert.NotNull(input);
var value = await input.GetAttributeAsync("value");
Assert.Equal("AC/DC", value);
}
[Fact]
public async Task EditAndSave_UpdatesRowWithNewValue()
{
// Arrange
await Page.GotoAsync("/Artists");
// Click edit
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
// Act - Change value
var newName = "AC/DC Updated " + Guid.NewGuid().ToString()[..8];
await Page.FillAsync("input[name='name']", newName);
// Click save
await Page.ClickAsync("button[type='submit']");
// Wait for response
await WaitForHtmxRequestAsync("handler=Update");
await Page.WaitForTimeoutAsync(200);
// Assert - Row should show new value
var rowContent = await Page.TextContentAsync("#artist-row-1, tr:first-of-type");
Assert.Contains(newName, rowContent);
// Form should be gone
var form = await Page.QuerySelectorAsync("#artist-row-1 form");
Assert.Null(form);
}
[Fact]
public async Task EditAndCancel_RestoresOriginalRow()
{
// Arrange
await Page.GotoAsync("/Artists");
var originalContent = await Page.TextContentAsync("#artist-row-1");
// Click edit
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
// Change value but don't save
await Page.FillAsync("input[name='name']", "Changed But Not Saved");
// Act - Click cancel (or press Escape)
var cancelButton = await Page.QuerySelectorAsync(
"button[hx-get*='Cancel'], button[type='button']:has-text('Cancel')");
if (cancelButton != null)
{
await cancelButton.ClickAsync();
await WaitForHtmxRequestAsync("handler=Cancel");
}
else
{
// Try Escape key
await Page.Keyboard.PressAsync("Escape");
}
await Page.WaitForTimeoutAsync(200);
// Assert - Should show original content
var restoredContent = await Page.TextContentAsync("#artist-row-1");
Assert.Contains("AC/DC", restoredContent);
Assert.DoesNotContain("Changed But Not Saved", restoredContent);
}
[Fact]
public async Task MultipleEdits_WorkIndependently()
{
// Arrange - Need at least 2 artists
await Page.GotoAsync("/Artists");
// Start editing first artist
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("#artist-row-1 input[name='name'], input[name='name']");
// Assert - Can still see other rows
var row2 = await Page.QuerySelectorAsync("#artist-row-2");
Assert.NotNull(row2);
// Should be able to save without affecting other rows
await Page.FillAsync("input[name='name']", "Edited First");
await Page.ClickAsync("button[type='submit']");
await WaitForHtmxRequestAsync("handler=Update");
// Second row should be unchanged
var row2Content = await Page.TextContentAsync("#artist-row-2");
Assert.Contains("Accept", row2Content);
}
}
#
22.5.4 Testing OOB Updates in Browser
Verify that OOB swaps update multiple elements.
Browser/Artists/DeleteWithOobTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Artists;
public class DeleteWithOobTests : PlaywrightTestBase
{
public DeleteWithOobTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task DeleteArtist_RemovesRow_AndUpdatesStats()
{
// Arrange
await Page.GotoAsync("/Artists");
// Get initial stats
var statsElement = await Page.QuerySelectorAsync(
"#artist-count, .stat-count, [data-stat='artists']");
var initialStats = statsElement != null
? await statsElement.TextContentAsync()
: null;
// Get initial row count
var initialRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
var initialRowCount = initialRows.Count;
// Find delete button for last artist (to minimize test interference)
var deleteButton = await Page.QuerySelectorAsync(
"#artist-row-5 button[hx-delete], #artist-row-5 .delete-btn");
if (deleteButton == null)
{
// Skip if no delete button found
return;
}
// Act - Click delete
// Handle confirmation dialog
Page.Dialog += async (_, dialog) =>
{
await dialog.AcceptAsync();
};
await deleteButton.ClickAsync();
// Wait for delete request
await WaitForHtmxRequestAsync("handler=Delete");
await Page.WaitForTimeoutAsync(300);
// Assert - Row should be removed
var deletedRow = await Page.QuerySelectorAsync("#artist-row-5");
Assert.Null(deletedRow);
// Row count should decrease
var finalRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
Assert.Equal(initialRowCount - 1, finalRows.Count);
// Stats should be updated (OOB)
if (statsElement != null && initialStats != null)
{
var finalStats = await statsElement.TextContentAsync();
// Stats should have changed
Assert.NotEqual(initialStats, finalStats);
}
}
[Fact]
public async Task DeleteArtist_ShowsToastNotification()
{
// Arrange
await Page.GotoAsync("/Artists");
var deleteButton = await Page.QuerySelectorAsync(
"button[hx-delete], .delete-btn");
if (deleteButton == null) return;
// Handle confirmation
Page.Dialog += async (_, dialog) => await dialog.AcceptAsync();
// Act
await deleteButton.ClickAsync();
await WaitForHtmxRequestAsync("handler=Delete");
// Assert - Toast should appear
var toast = await Page.WaitForSelectorAsync(
".toast, .notification, [class*='toast']",
new() { Timeout = 3000 });
Assert.NotNull(toast);
}
[Fact]
public async Task DeleteArtist_CancelConfirmation_KeepsRow()
{
// Arrange
await Page.GotoAsync("/Artists");
var deleteButton = await Page.QuerySelectorAsync(
"#artist-row-1 button[hx-delete], #artist-row-1 .delete-btn");
if (deleteButton == null) return;
// Handle confirmation - dismiss it
Page.Dialog += async (_, dialog) => await dialog.DismissAsync();
// Act
await deleteButton.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Assert - Row should still exist
var row = await Page.QuerySelectorAsync("#artist-row-1");
Assert.NotNull(row);
}
}
#
22.5.5 Testing Modals and Dialogs
Test the complete modal workflow.
Browser/Artists/CreateArtistModalTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Artists;
public class CreateArtistModalTests : PlaywrightTestBase
{
public CreateArtistModalTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task ClickAddButton_OpensModal()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Click add button
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
// Wait for modal to appear
await Page.WaitForSelectorAsync(
"#modal-container form, .modal form, [class*='modal'] form");
// Assert - Modal should be visible
var modal = await Page.QuerySelectorAsync(
"#modal-container, .modal, [class*='modal']");
Assert.NotNull(modal);
var isVisible = await modal.IsVisibleAsync();
Assert.True(isVisible, "Modal should be visible");
}
[Fact]
public async Task CreateArtist_ClosesModal_AndAddsRow()
{
// Arrange
await Page.GotoAsync("/Artists");
var initialRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
var initialCount = initialRows.Count;
// Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
// Act - Fill form
var newArtistName = "New Test Artist " + Guid.NewGuid().ToString()[..8];
await Page.FillAsync("input[name='name']", newArtistName);
// Submit
await Page.ClickAsync(
"#modal-container button[type='submit'], .modal button[type='submit']");
// Wait for response
await WaitForHtmxRequestAsync("handler=Create");
await Page.WaitForTimeoutAsync(300);
// Assert - Modal should close
var modalContent = await Page.QuerySelectorAsync("#modal-container form");
Assert.Null(modalContent);
// New row should appear
var content = await Page.ContentAsync();
Assert.Contains(newArtistName, content);
// Row count should increase
var finalRows = await Page.QuerySelectorAllAsync("tr[id^='artist-row-']");
Assert.True(finalRows.Count >= initialCount, "Should have at least as many rows");
}
[Fact]
public async Task CreateArtist_WithEmptyName_ShowsValidation()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
// Act - Submit without filling
await Page.ClickAsync(
"#modal-container button[type='submit'], .modal button[type='submit']");
// Wait a moment for validation
await Page.WaitForTimeoutAsync(200);
// Assert - Should show validation error or browser validation
var input = await Page.QuerySelectorAsync("input[name='name']");
Assert.NotNull(input);
// Check for HTML5 validation or custom validation message
var validationMessage = await input.EvaluateAsync<string>(
"el => el.validationMessage");
var hasCustomError = await Page.QuerySelectorAsync(
".validation-error, .field-validation-error, [class*='error']");
Assert.True(
!string.IsNullOrEmpty(validationMessage) || hasCustomError != null,
"Should show validation error for empty name");
// Modal should still be open
var form = await Page.QuerySelectorAsync("#modal-container form, .modal form");
Assert.NotNull(form);
}
[Fact]
public async Task CloseModal_ByClickingOutside_OrCloseButton()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("#modal-container form, .modal form");
// Act - Try close button first
var closeButton = await Page.QuerySelectorAsync(
".modal-close, .close-btn, button[aria-label='Close'], button:has-text('×')");
if (closeButton != null)
{
await closeButton.ClickAsync();
}
else
{
// Try pressing Escape
await Page.Keyboard.PressAsync("Escape");
}
await Page.WaitForTimeoutAsync(300);
// Assert - Modal should close
var form = await Page.QuerySelectorAsync("#modal-container form");
Assert.Null(form);
}
[Fact]
public async Task CreateArtist_ShowsSuccessToast()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open modal and create
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "Toast Test Artist");
await Page.ClickAsync("button[type='submit']");
// Act - Wait for success
await WaitForHtmxRequestAsync("handler=Create");
// Assert - Toast should appear
try
{
var toast = await Page.WaitForSelectorAsync(
".toast, .notification, [class*='toast']",
new() { Timeout = 3000 });
Assert.NotNull(toast);
var toastText = await toast.TextContentAsync();
Assert.Contains("success", toastText?.ToLower() ?? "");
}
catch (TimeoutException)
{
// Toast might auto-dismiss quickly or not be implemented
// This is acceptable for some implementations
}
}
[Fact]
public async Task Modal_FocusesFirstInput_OnOpen()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.WaitForTimeoutAsync(100); // Allow focus to settle
// Assert - First input should be focused
var focusedElement = await Page.EvaluateAsync<string>(
"document.activeElement?.name || document.activeElement?.tagName");
Assert.True(
focusedElement == "name" || focusedElement == "INPUT",
$"Expected name input to be focused, got: {focusedElement}");
}
}
#
Running Browser Tests
# Run all browser tests
dotnet test --filter "FullyQualifiedName~Browser"
# Run with headed browser for debugging
# (Modify PlaywrightFixture to set Headless = false)
# Run specific test with trace
dotnet test --filter "FullyQualifiedName~CreateArtistModalTests"
Browser tests verify the complete user experience, catching issues that integration tests miss: JavaScript errors, htmx processing, DOM updates, and visual feedback. They run slower than integration tests, so use them for critical user workflows rather than exhaustive coverage.
#
22.6 Testing Hyperscript Behaviors
Hyperscript runs entirely in the browser. Testing Hyperscript behaviors requires browser automation to verify that classes toggle, focus moves, and timed behaviors work correctly.
#
22.6.1 Testing Client-Side State Changes
Hyperscript often manages UI state through class changes. Test that clicking elements updates classes correctly.
Browser/Hyperscript/TabSelectionTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Hyperscript;
public class TabSelectionTests : PlaywrightTestBase
{
public TabSelectionTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task ClickTab_AddsActiveClass()
{
// Arrange
await Page.GotoAsync("/Tracks");
// Find tabs
var tabs = await Page.QuerySelectorAllAsync(".genre-tab, .tab, [role='tab']");
if (tabs.Count < 2) return; // Skip if no tabs
var secondTab = tabs[1];
// Act - Click second tab
await secondTab.ClickAsync();
await Page.WaitForTimeoutAsync(100);
// Assert - Second tab should have active class
var hasActive = await secondTab.EvaluateAsync<bool>(
"el => el.classList.contains('active') || el.classList.contains('selected') || el.getAttribute('aria-selected') === 'true'");
Assert.True(hasActive, "Clicked tab should have active state");
}
[Fact]
public async Task ClickTab_RemovesActiveFromPreviousTab()
{
// Arrange
await Page.GotoAsync("/Tracks");
var tabs = await Page.QuerySelectorAllAsync(".genre-tab, .tab, [role='tab']");
if (tabs.Count < 2) return;
var firstTab = tabs[0];
var secondTab = tabs[1];
// Verify first tab starts active
var firstWasActive = await firstTab.EvaluateAsync<bool>(
"el => el.classList.contains('active') || el.classList.contains('selected')");
// Act - Click second tab
await secondTab.ClickAsync();
await Page.WaitForTimeoutAsync(100);
// Assert - First tab should no longer be active
var firstStillActive = await firstTab.EvaluateAsync<bool>(
"el => el.classList.contains('active') || el.classList.contains('selected')");
if (firstWasActive)
{
Assert.False(firstStillActive, "Previous tab should lose active state");
}
}
[Fact]
public async Task ClickTab_UpdatesTabPanel()
{
// Arrange
await Page.GotoAsync("/Tracks");
var tabs = await Page.QuerySelectorAllAsync(".genre-tab, .tab, [role='tab']");
if (tabs.Count < 2) return;
// Get initial panel content
var panel = await Page.QuerySelectorAsync(".tab-panel, [role='tabpanel']");
var initialContent = panel != null ? await panel.TextContentAsync() : "";
// Act - Click different tab
await tabs[1].ClickAsync();
// Wait for content to potentially change (htmx or Hyperscript)
await Page.WaitForTimeoutAsync(300);
// Assert - Panel content or visibility may change
// Implementation varies: could be htmx load or Hyperscript show/hide
}
[Fact]
public async Task TabSelection_PersistsAfterOtherInteractions()
{
// Arrange
await Page.GotoAsync("/Tracks");
var tabs = await Page.QuerySelectorAllAsync(".genre-tab, .tab");
if (tabs.Count < 2) return;
// Select second tab
await tabs[1].ClickAsync();
await Page.WaitForTimeoutAsync(100);
// Perform another action (like search)
var searchInput = await Page.QuerySelectorAsync("input[name='search']");
if (searchInput != null)
{
await searchInput.FillAsync("test");
await Page.WaitForTimeoutAsync(500);
}
// Assert - Tab selection should persist
var isStillActive = await tabs[1].EvaluateAsync<bool>(
"el => el.classList.contains('active') || el.classList.contains('selected')");
Assert.True(isStillActive, "Tab selection should persist after other interactions");
}
}
#
22.6.2 Testing Keyboard Interactions
Test keyboard shortcuts implemented with Hyperscript.
Browser/Hyperscript/KeyboardNavigationTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Hyperscript;
public class KeyboardNavigationTests : PlaywrightTestBase
{
public KeyboardNavigationTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task EscapeKey_CancelsEditMode()
{
// Arrange
await Page.GotoAsync("/Artists");
// Enter edit mode
var editButton = await Page.QuerySelectorAsync(
"#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
if (editButton == null) return;
await editButton.ClickAsync();
await Page.WaitForSelectorAsync("input[name='name']");
// Verify we're in edit mode
var formBefore = await Page.QuerySelectorAsync("#artist-row-1 form, form[hx-post*='Update']");
Assert.NotNull(formBefore);
// Act - Press Escape
await Page.Keyboard.PressAsync("Escape");
await Page.WaitForTimeoutAsync(300);
// Assert - Should exit edit mode
var formAfter = await Page.QuerySelectorAsync("#artist-row-1 form");
Assert.Null(formAfter);
// Original content should be restored
var rowContent = await Page.TextContentAsync("#artist-row-1");
Assert.Contains("AC/DC", rowContent);
}
[Fact]
public async Task EnterKey_SubmitsForm()
{
// Arrange
await Page.GotoAsync("/Artists");
// Enter edit mode
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
// Change value
var uniqueName = "Enter Test " + Guid.NewGuid().ToString()[..6];
await Page.FillAsync("input[name='name']", uniqueName);
// Act - Press Enter
await Page.Keyboard.PressAsync("Enter");
// Wait for submission
await WaitForHtmxRequestAsync("handler=Update");
await Page.WaitForTimeoutAsync(200);
// Assert - Form should be gone and value updated
var form = await Page.QuerySelectorAsync("#artist-row-1 form");
Assert.Null(form);
var rowContent = await Page.TextContentAsync("#artist-row-1");
Assert.Contains(uniqueName, rowContent);
}
[Fact]
public async Task EscapeKey_DoesNotSubmitChanges()
{
// Arrange
await Page.GotoAsync("/Artists");
// Enter edit mode
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
// Change value but don't submit
await Page.FillAsync("input[name='name']", "Should Not Save");
// Act - Press Escape
await Page.Keyboard.PressAsync("Escape");
await Page.WaitForTimeoutAsync(300);
// Assert - Original value should be shown
var rowContent = await Page.TextContentAsync("#artist-row-1");
Assert.Contains("AC/DC", rowContent);
Assert.DoesNotContain("Should Not Save", rowContent);
}
[Fact]
public async Task TabKey_NavigatesBetweenFormFields()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open create modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
// Focus first input
await Page.FocusAsync("input[name='name']");
// Act - Press Tab
await Page.Keyboard.PressAsync("Tab");
await Page.WaitForTimeoutAsync(50);
// Assert - Focus should move to next focusable element
var focusedTag = await Page.EvaluateAsync<string>(
"document.activeElement?.tagName");
Assert.True(
focusedTag == "BUTTON" || focusedTag == "INPUT",
$"Tab should move focus to next element, focused: {focusedTag}");
}
[Fact]
public async Task ShortcutKeys_WorkInContext()
{
// Arrange
await Page.GotoAsync("/Artists");
// Test Ctrl+F or / for search focus (if implemented)
var searchInput = await Page.QuerySelectorAsync("input[name='search']");
if (searchInput == null) return;
// Click elsewhere first
await Page.ClickAsync("body");
// Act - Try keyboard shortcut
await Page.Keyboard.PressAsync("/");
await Page.WaitForTimeoutAsync(100);
// Assert - Check if search is focused
var focusedName = await Page.EvaluateAsync<string>(
"document.activeElement?.name");
// This test documents expected behavior - may or may not be implemented
}
}
#
22.6.3 Testing Auto-Dismiss Behaviors
Test time-based behaviors like toast notifications that auto-dismiss.
Browser/Hyperscript/ToastAutoDismissTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Hyperscript;
public class ToastAutoDismissTests : PlaywrightTestBase
{
public ToastAutoDismissTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task Toast_AppearsOnSuccess()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open modal and create artist
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "Toast Test Artist");
// Act - Submit
await Page.ClickAsync("button[type='submit']");
await WaitForHtmxRequestAsync("handler=Create");
// Assert - Toast should appear
var toast = await Page.WaitForSelectorAsync(
".toast, .notification, [class*='toast']",
new() { Timeout = 3000 });
Assert.NotNull(toast);
var isVisible = await toast.IsVisibleAsync();
Assert.True(isVisible, "Toast should be visible");
}
[Fact]
public async Task Toast_AutoDismisses_AfterTimeout()
{
// Arrange
await Page.GotoAsync("/Artists");
// Trigger action that shows toast
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "Auto Dismiss Test");
await Page.ClickAsync("button[type='submit']");
// Wait for toast to appear
var toast = await Page.WaitForSelectorAsync(
".toast, .notification",
new() { Timeout = 3000 });
if (toast == null) return; // Skip if no toast implementation
// Verify toast is visible
Assert.True(await toast.IsVisibleAsync());
// Act - Wait for auto-dismiss (typically 5 seconds + buffer)
await Page.WaitForTimeoutAsync(6000);
// Assert - Toast should be gone
var toastAfter = await Page.QuerySelectorAsync(".toast, .notification");
if (toastAfter != null)
{
var stillVisible = await toastAfter.IsVisibleAsync();
Assert.False(stillVisible, "Toast should auto-dismiss after timeout");
}
}
[Fact]
public async Task Toast_CanBeManuallyDismissed()
{
// Arrange
await Page.GotoAsync("/Artists");
// Trigger toast
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "Manual Dismiss Test");
await Page.ClickAsync("button[type='submit']");
var toast = await Page.WaitForSelectorAsync(
".toast, .notification",
new() { Timeout = 3000 });
if (toast == null) return;
// Act - Click close button on toast
var closeButton = await toast.QuerySelectorAsync(
".close, .dismiss, button, [aria-label='Close']");
if (closeButton != null)
{
await closeButton.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Assert - Toast should be gone immediately
var toastAfter = await Page.QuerySelectorAsync(".toast:visible, .notification:visible");
Assert.Null(toastAfter);
}
}
[Fact]
public async Task MultipleToasts_StackCorrectly()
{
// Arrange
await Page.GotoAsync("/Artists");
// Trigger multiple actions quickly
for (int i = 0; i < 2; i++)
{
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", $"Stack Test {i}");
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(500);
}
// Assert - Should handle multiple toasts gracefully
var toasts = await Page.QuerySelectorAllAsync(".toast, .notification");
// Implementation may stack, queue, or replace - all are valid
// This test documents the behavior
}
}
#
22.6.4 Testing Focus Management
Test that focus moves correctly for accessibility and usability.
Browser/Hyperscript/EditFormFocusTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Hyperscript;
public class EditFormFocusTests : PlaywrightTestBase
{
public EditFormFocusTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task EditForm_FocusesNameInput_OnOpen()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Click edit
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.WaitForTimeoutAsync(100); // Allow focus script to run
// Assert - Input should be focused
var focusedElement = await Page.EvaluateAsync<string>(
"document.activeElement?.name || document.activeElement?.tagName");
Assert.True(
focusedElement == "name" || focusedElement == "INPUT",
$"Name input should be focused, got: {focusedElement}");
}
[Fact]
public async Task EditForm_SelectsText_OnFocus()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Click edit
await Page.ClickAsync("#artist-row-1 button[hx-get*='Edit'], #artist-row-1 .edit-btn");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.WaitForTimeoutAsync(100);
// Assert - Text should be selected
var selectionLength = await Page.EvaluateAsync<int>(@"
const input = document.querySelector('input[name=""name""]');
if (input && input.selectionStart !== input.selectionEnd) {
return input.selectionEnd - input.selectionStart;
}
return 0;
");
// If text selection is implemented, it should select the content
// This test documents the expected behavior
if (selectionLength > 0)
{
Assert.True(selectionLength > 0, "Text should be selected");
}
}
[Fact]
public async Task Modal_FocusesFirstInput_OnOpen()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("#modal-container input, .modal input");
await Page.WaitForTimeoutAsync(150);
// Assert - First input should be focused
var focusedElement = await Page.EvaluateAsync<string>(
"document.activeElement?.tagName");
Assert.Equal("INPUT", focusedElement);
}
[Fact]
public async Task Modal_TrapsFocus()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open modal
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("#modal-container, .modal");
// Act - Tab through all elements
for (int i = 0; i < 10; i++)
{
await Page.Keyboard.PressAsync("Tab");
await Page.WaitForTimeoutAsync(50);
}
// Assert - Focus should still be within modal
var focusedInModal = await Page.EvaluateAsync<bool>(@"
const modal = document.querySelector('#modal-container, .modal');
return modal && modal.contains(document.activeElement);
");
Assert.True(focusedInModal, "Focus should be trapped within modal");
}
[Fact]
public async Task Modal_RestoresFocus_OnClose()
{
// Arrange
await Page.GotoAsync("/Artists");
var addButton = await Page.QuerySelectorAsync(
"#add-artist-btn, button[hx-get*='CreateForm']");
Assert.NotNull(addButton);
// Focus and click the add button
await addButton.FocusAsync();
await addButton.ClickAsync();
await Page.WaitForSelectorAsync("#modal-container form, .modal form");
// Act - Close modal with Escape
await Page.Keyboard.PressAsync("Escape");
await Page.WaitForTimeoutAsync(300);
// Assert - Focus should return to trigger element
var focusedId = await Page.EvaluateAsync<string>(
"document.activeElement?.id || document.activeElement?.className");
// Focus restoration is a nice-to-have accessibility feature
// This test documents whether it's implemented
}
}
#
22.7 Testing Error Scenarios
Test that your application handles errors gracefully, showing appropriate messages and maintaining usable state.
#
22.7.1 Testing Server Error Handling
Test responses to various HTTP error status codes.
Browser/Errors/ServerErrorTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Errors;
public class ServerErrorTests : PlaywrightTestBase
{
public ServerErrorTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task Error404_ShowsNotFoundMessage()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Try to edit non-existent artist
await Page.EvaluateAsync(@"
htmx.ajax('GET', '/Artists?handler=Edit&id=99999', {target: '#artist-list'});
");
await Page.WaitForTimeoutAsync(500);
// Assert - Should show error indication
var hasError = await Page.QuerySelectorAsync(
".toast.error, .error-message, [class*='error']") != null;
// Or the element might show an inline error
var content = await Page.ContentAsync();
var showsError = content.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
content.Contains("error", StringComparison.OrdinalIgnoreCase) ||
hasError;
// This test documents error handling behavior
}
[Fact]
public async Task Error500_ShowsServerErrorMessage()
{
// Arrange
await Page.GotoAsync("/Artists");
// We need a way to trigger a 500 error
// Option 1: Use a test endpoint
// Option 2: Intercept request and return error
await Page.RouteAsync("**/Artists*handler=SimulateError*", async route =>
{
await route.FulfillAsync(new()
{
Status = 500,
Body = "Internal Server Error"
});
});
// Act - Make request that will fail
await Page.EvaluateAsync(@"
htmx.ajax('GET', '/Artists?handler=SimulateError', {target: '#artist-list'});
");
await Page.WaitForTimeoutAsync(500);
// Assert - Error should be indicated to user
var toast = await Page.QuerySelectorAsync(".toast.error, .toast-error");
// Error handling varies by implementation
}
[Fact]
public async Task Error400_ShowsBadRequestMessage()
{
// Arrange
await Page.GotoAsync("/Artists");
// Intercept to return 400
await Page.RouteAsync("**/Artists*handler=Create*", async route =>
{
await route.FulfillAsync(new()
{
Status = 400,
ContentType = "text/html",
Body = "<div class='error'>Bad Request: Invalid data</div>"
});
});
// Open create form and submit
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "Test");
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(500);
// Assert - Should show validation/error feedback
var content = await Page.ContentAsync();
Assert.True(
content.Contains("error", StringComparison.OrdinalIgnoreCase) ||
content.Contains("invalid", StringComparison.OrdinalIgnoreCase) ||
content.Contains("Bad Request", StringComparison.OrdinalIgnoreCase),
"Should indicate bad request error");
}
[Fact]
public async Task ErrorResponse_PreservesPageState()
{
// Arrange
await Page.GotoAsync("/Artists");
var initialContent = await Page.ContentAsync();
// Intercept with error
await Page.RouteAsync("**/Artists*handler=List*", async route =>
{
await route.FulfillAsync(new()
{
Status = 500,
Body = "Server Error"
});
});
// Act - Try to search (will fail)
await Page.FillAsync("input[name='search']", "test");
await Page.WaitForTimeoutAsync(1000);
// Assert - Page should still be functional
var artistList = await Page.QuerySelectorAsync(
"#artist-list, .artist-table, table");
Assert.NotNull(artistList);
}
}
#
22.7.2 Testing Validation Errors
Test form validation feedback.
Browser/Errors/FormValidationTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Errors;
public class FormValidationTests : PlaywrightTestBase
{
public FormValidationTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task EmptyRequiredField_ShowsValidationError()
{
// Arrange
await Page.GotoAsync("/Artists");
// Open create form
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
// Clear any default value
await Page.FillAsync("input[name='name']", "");
// Act - Try to submit
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(300);
// Assert - Should show validation error
// Check for HTML5 validation
var validationMessage = await Page.EvaluateAsync<string>(
"document.querySelector('input[name=\"name\"]')?.validationMessage");
// Or server-side validation message
var errorMessage = await Page.QuerySelectorAsync(
".validation-error, .field-validation-error, .error-message, [class*='error']");
Assert.True(
!string.IsNullOrEmpty(validationMessage) || errorMessage != null,
"Should show validation error for empty required field");
}
[Fact]
public async Task ValidationError_FormStaysOpen()
{
// Arrange
await Page.GotoAsync("/Artists");
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "");
// Act - Submit invalid form
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(300);
// Assert - Form should still be visible
var form = await Page.QuerySelectorAsync(
"#modal-container form, .modal form, form[hx-post*='Create']");
Assert.NotNull(form);
}
[Fact]
public async Task ValidationError_CanCorrectAndResubmit()
{
// Arrange
await Page.GotoAsync("/Artists");
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
// Submit empty (invalid)
await Page.FillAsync("input[name='name']", "");
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(300);
// Act - Fix and resubmit
var validName = "Corrected Artist " + Guid.NewGuid().ToString()[..6];
await Page.FillAsync("input[name='name']", validName);
await Page.ClickAsync("button[type='submit']");
// Wait for success
try
{
await WaitForHtmxRequestAsync("handler=Create");
await Page.WaitForTimeoutAsync(300);
// Assert - Should succeed
var content = await Page.ContentAsync();
Assert.Contains(validName, content);
}
catch
{
// If HTML5 validation prevents submission, that's also valid
}
}
[Fact]
public async Task ServerValidationError_DisplaysInForm()
{
// Arrange
await Page.GotoAsync("/Artists");
// Intercept to return validation error
await Page.RouteAsync("**/Artists*handler=Create*", async route =>
{
await route.FulfillAsync(new()
{
Status = 200,
ContentType = "text/html",
Body = @"
<form hx-post='/Artists?handler=Create'>
<input name='name' value='' class='input-validation-error' />
<span class='field-validation-error'>Name is required</span>
<button type='submit'>Save</button>
</form>"
});
});
// Open and submit form
await Page.ClickAsync("#add-artist-btn, button[hx-get*='CreateForm']");
await Page.WaitForSelectorAsync("input[name='name']");
await Page.FillAsync("input[name='name']", "x");
await Page.ClickAsync("button[type='submit']");
await Page.WaitForTimeoutAsync(300);
// Assert - Server validation message should display
var errorSpan = await Page.QuerySelectorAsync(".field-validation-error");
Assert.NotNull(errorSpan);
var errorText = await errorSpan.TextContentAsync();
Assert.Contains("required", errorText ?? "", StringComparison.OrdinalIgnoreCase);
}
}
#
22.7.3 Testing Network Errors
Test behavior when network connectivity is lost.
Browser/Errors/NetworkErrorTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Errors;
public class NetworkErrorTests : PlaywrightTestBase
{
public NetworkErrorTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task OfflineMode_ShowsError()
{
// Arrange
await Page.GotoAsync("/Artists");
// Go offline
await Context.SetOfflineAsync(true);
// Act - Try to perform action
await Page.FillAsync("input[name='search']", "test");
// Wait for error handling
await Page.WaitForTimeoutAsync(2000);
// Assert - Should indicate network error
// htmx triggers htmx:sendError event
var hasErrorIndication = await Page.EvaluateAsync<bool>(@"
document.querySelector('.toast.error, .error-message, .network-error') !== null ||
document.body.classList.contains('htmx-request-error')
");
// Restore network for cleanup
await Context.SetOfflineAsync(false);
// Network error handling varies by implementation
}
[Fact]
public async Task NetworkRecovery_AllowsRetry()
{
// Arrange
await Page.GotoAsync("/Artists");
// Go offline
await Context.SetOfflineAsync(true);
// Try action (will fail)
await Page.FillAsync("input[name='search']", "AC");
await Page.WaitForTimeoutAsync(1000);
// Act - Restore network
await Context.SetOfflineAsync(false);
// Retry the action
await Page.FillAsync("input[name='search']", "AC/DC");
await Page.WaitForTimeoutAsync(1000);
// Assert - Should work after recovery
var content = await Page.ContentAsync();
// Results may or may not appear depending on implementation
}
[Fact]
public async Task SlowNetwork_ShowsLoadingIndicator()
{
// Arrange
await Page.GotoAsync("/Artists");
// Slow down all requests
await Page.RouteAsync("**/*", async route =>
{
await Task.Delay(2000); // 2 second delay
await route.ContinueAsync();
});
// Act - Trigger request
var searchTask = Page.FillAsync("input[name='search']", "test");
// Check for loading indicator immediately
await Page.WaitForTimeoutAsync(100);
var hasIndicator = await Page.QuerySelectorAsync(
".htmx-indicator:not([style*='display: none']), .loading, .spinner") != null;
// Or check for htmx-request class
var hasRequestClass = await Page.EvaluateAsync<bool>(
"document.querySelector('.htmx-request') !== null");
// Clean up route
await Page.UnrouteAsync("**/*");
// Loading indication is good UX but not required
}
}
#
22.7.4 Testing Timeout Behavior
Test handling of slow or timing-out requests.
Browser/Errors/TimeoutTests.cs
using ChinookDashboard.Tests.Browser.Fixtures;
namespace ChinookDashboard.Tests.Browser.Errors;
public class TimeoutTests : PlaywrightTestBase
{
public TimeoutTests(PlaywrightFixture fixture) : base(fixture) { }
[Fact]
public async Task SlowRequest_ShowsLoadingState()
{
// Arrange
await Page.GotoAsync("/Artists");
// Intercept and delay response
await Page.RouteAsync("**/Artists*handler=List*", async route =>
{
await Task.Delay(3000); // 3 second delay
await route.ContinueAsync();
});
// Act - Trigger search
_ = Page.FillAsync("input[name='search']", "slow");
// Check loading state
await Page.WaitForTimeoutAsync(500);
// Assert - Should show loading indication
var isLoading = await Page.EvaluateAsync<bool>(@"
document.querySelector('.htmx-request') !== null ||
document.querySelector('.htmx-indicator:not(.hidden)') !== null ||
document.querySelector('.loading') !== null
");
// Clean up
await Page.UnrouteAsync("**/Artists*handler=List*");
// This test documents loading state behavior
}
[Fact]
public async Task TimeoutResponse_HandledGracefully()
{
// Arrange
await Page.GotoAsync("/Artists");
// Configure htmx timeout (if not already set)
await Page.EvaluateAsync("htmx.config.timeout = 1000"); // 1 second
// Intercept and delay beyond timeout
await Page.RouteAsync("**/Artists*handler=List*", async route =>
{
await Task.Delay(5000); // 5 seconds - beyond timeout
await route.ContinueAsync();
});
// Act - Trigger request
await Page.FillAsync("input[name='search']", "timeout");
// Wait for timeout to trigger
await Page.WaitForTimeoutAsync(2000);
// Assert - Page should still be usable
var searchInput = await Page.QuerySelectorAsync("input[name='search']");
Assert.NotNull(searchInput);
// Clean up
await Page.UnrouteAsync("**/Artists*handler=List*");
}
[Fact]
public async Task AbortedRequest_DoesNotCorruptState()
{
// Arrange
await Page.GotoAsync("/Artists");
// Act - Start request then navigate away
await Page.FillAsync("input[name='search']", "will be aborted");
// Immediately navigate away
await Page.GotoAsync("/Artists");
// Assert - Page should load normally
var content = await Page.ContentAsync();
Assert.Contains("Artists", content);
var table = await Page.QuerySelectorAsync("table, #artist-list");
Assert.NotNull(table);
}
}
#
22.8 Test Organization and Best Practices
Good test organization makes tests easier to maintain, run, and understand.
#
22.8.1 Organizing Test Files
Structure your test project by test type and feature:
ChinookDashboard.Tests/
├── ChinookDashboard.Tests.csproj
├── Unit/
│ ├── Services/
│ │ ├── ArtistServiceTests.cs
│ │ ├── AlbumServiceTests.cs
│ │ └── TrackServiceTests.cs
│ ├── Models/
│ │ ├── PaginatedListTests.cs
│ │ └── TrackSummaryTests.cs
│ └── Helpers/
│ ├── HtmxExtensionTests.cs
│ └── ToastHelperTests.cs
├── Integration/
│ ├── Fixtures/
│ │ └── ChinookTestFactory.cs
│ ├── Common/
│ │ ├── IntegrationTestBase.cs
│ │ ├── HttpClientHtmxExtensions.cs
│ │ ├── HtmlParsingHelper.cs
│ │ └── HtmxAssertions.cs
│ ├── Artists/
│ │ ├── ArtistPageTests.cs
│ │ ├── ArtistPartialTests.cs
│ │ ├── ArtistEditFormTests.cs
│ │ ├── ArtistResponseHeaderTests.cs
│ │ └── ArtistOobTests.cs
│ └── Tracks/
│ └── TrackRowAttributeTests.cs
├── Browser/
│ ├── Fixtures/
│ │ └── PlaywrightFixture.cs
│ ├── Common/
│ │ └── PlaywrightTestBase.cs
│ ├── Artists/
│ │ ├── ArtistSearchTests.cs
│ │ ├── InlineEditTests.cs
│ │ ├── DeleteWithOobTests.cs
│ │ └── CreateArtistModalTests.cs
│ ├── Hyperscript/
│ │ ├── TabSelectionTests.cs
│ │ ├── KeyboardNavigationTests.cs
│ │ ├── ToastAutoDismissTests.cs
│ │ └── EditFormFocusTests.cs
│ └── Errors/
│ ├── ServerErrorTests.cs
│ ├── FormValidationTests.cs
│ ├── NetworkErrorTests.cs
│ └── TimeoutTests.cs
└── TestData/
└── SeedData.cs
Naming Conventions:
- Test classes:
{Feature}{TestType}Tests.cs(e.g.,ArtistSearchTests.cs) - Test methods:
{Action}_{Condition}_{ExpectedResult}(e.g.,Search_WithMatchingTerm_ReturnsFilteredResults) - Use descriptive names that read like requirements
#
22.8.2 Test Data Management
Create a centralized seeding class for consistent test data.
TestData/SeedData.cs
using ChinookDashboard.Data;
using ChinookDashboard.Data.Entities;
namespace ChinookDashboard.Tests.TestData;
public static class SeedData
{
public static void Initialize(ChinookContext context)
{
// Clear existing data
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
// Seed in order of dependencies
SeedGenres(context);
SeedArtists(context);
SeedAlbums(context);
SeedTracks(context);
context.SaveChanges();
}
public static void SeedGenres(ChinookContext context)
{
var genres = new[]
{
new Genre { Id = 1, Name = "Rock" },
new Genre { Id = 2, Name = "Metal" },
new Genre { Id = 3, Name = "Jazz" },
new Genre { Id = 4, Name = "Blues" },
new Genre { Id = 5, Name = "Classical" }
};
context.Genres.AddRange(genres);
}
public static void SeedArtists(ChinookContext context)
{
var artists = new[]
{
new Artist { Id = 1, Name = "AC/DC" },
new Artist { Id = 2, Name = "Accept" },
new Artist { Id = 3, Name = "Aerosmith" },
new Artist { Id = 4, Name = "Led Zeppelin" },
new Artist { Id = 5, Name = "Metallica" },
new Artist { Id = 6, Name = "Iron Maiden" },
new Artist { Id = 7, Name = "Black Sabbath" },
new Artist { Id = 8, Name = "Deep Purple" },
new Artist { Id = 9, Name = "Judas Priest" },
new Artist { Id = 10, Name = "Ozzy Osbourne" }
};
context.Artists.AddRange(artists);
}
public static void SeedAlbums(ChinookContext context)
{
var albums = new[]
{
// AC/DC albums
new Album { Id = 1, Title = "Back in Black", ArtistId = 1 },
new Album { Id = 2, Title = "Highway to Hell", ArtistId = 1 },
new Album { Id = 3, Title = "For Those About to Rock", ArtistId = 1 },
// Accept
new Album { Id = 4, Title = "Restless and Wild", ArtistId = 2 },
new Album { Id = 5, Title = "Balls to the Wall", ArtistId = 2 },
// Aerosmith
new Album { Id = 6, Title = "Get a Grip", ArtistId = 3 },
new Album { Id = 7, Title = "Pump", ArtistId = 3 },
// Led Zeppelin
new Album { Id = 8, Title = "Led Zeppelin IV", ArtistId = 4 },
new Album { Id = 9, Title = "Physical Graffiti", ArtistId = 4 },
// Metallica
new Album { Id = 10, Title = "Master of Puppets", ArtistId = 5 },
new Album { Id = 11, Title = "...And Justice for All", ArtistId = 5 },
new Album { Id = 12, Title = "The Black Album", ArtistId = 5 }
};
context.Albums.AddRange(albums);
}
public static void SeedTracks(ChinookContext context)
{
var tracks = new[]
{
// Back in Black tracks
new Track { Id = 1, Name = "Hells Bells", AlbumId = 1, GenreId = 1,
Milliseconds = 312000, UnitPrice = 0.99m },
new Track { Id = 2, Name = "Shoot to Thrill", AlbumId = 1, GenreId = 1,
Milliseconds = 317000, UnitPrice = 0.99m },
new Track { Id = 3, Name = "Back in Black", AlbumId = 1, GenreId = 1,
Milliseconds = 255000, UnitPrice = 0.99m },
// Highway to Hell tracks
new Track { Id = 4, Name = "Highway to Hell", AlbumId = 2, GenreId = 1,
Milliseconds = 208000, UnitPrice = 0.99m },
new Track { Id = 5, Name = "Girls Got Rhythm", AlbumId = 2, GenreId = 1,
Milliseconds = 203000, UnitPrice = 0.99m },
// Led Zeppelin IV
new Track { Id = 6, Name = "Stairway to Heaven", AlbumId = 8, GenreId = 1,
Milliseconds = 482000, UnitPrice = 0.99m },
new Track { Id = 7, Name = "Black Dog", AlbumId = 8, GenreId = 1,
Milliseconds = 226000, UnitPrice = 0.99m },
new Track { Id = 8, Name = "Rock and Roll", AlbumId = 8, GenreId = 1,
Milliseconds = 220000, UnitPrice = 0.99m },
// Master of Puppets
new Track { Id = 9, Name = "Battery", AlbumId = 10, GenreId = 2,
Milliseconds = 312000, UnitPrice = 0.99m },
new Track { Id = 10, Name = "Master of Puppets", AlbumId = 10, GenreId = 2,
Milliseconds = 515000, UnitPrice = 0.99m }
};
context.Tracks.AddRange(tracks);
}
/// <summary>
/// Creates a minimal dataset for fast tests.
/// </summary>
public static void InitializeMinimal(ChinookContext context)
{
context.Database.EnsureCreated();
context.Genres.Add(new Genre { Id = 1, Name = "Rock" });
context.Artists.AddRange(
new Artist { Id = 1, Name = "Test Artist 1" },
new Artist { Id = 2, Name = "Test Artist 2" }
);
context.SaveChanges();
}
/// <summary>
/// Resets data to initial state (for test isolation).
/// </summary>
public static void Reset(ChinookContext context)
{
context.Tracks.RemoveRange(context.Tracks);
context.Albums.RemoveRange(context.Albums);
context.Artists.RemoveRange(context.Artists);
context.Genres.RemoveRange(context.Genres);
context.SaveChanges();
Initialize(context);
}
}
#
22.8.3 Test Utilities and Shared Code
Create helpers for common testing patterns.
Browser/Common/PlaywrightHelpers.cs
using Microsoft.Playwright;
namespace ChinookDashboard.Tests.Browser.Common;
public static class PlaywrightHelpers
{
/// <summary>
/// Takes a screenshot with timestamp.
/// </summary>
public static async Task CaptureScreenshotAsync(
IPage page,
string testName,
string suffix = "")
{
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var fileName = $"{testName}{suffix}_{timestamp}.png";
var path = Path.Combine("TestResults", "Screenshots", fileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = path,
FullPage = true
});
}
/// <summary>
/// Captures trace for debugging failed tests.
/// </summary>
public static async Task StartTracingAsync(IBrowserContext context, string testName)
{
await context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
Sources = true
});
}
public static async Task StopTracingAsync(IBrowserContext context, string testName)
{
var path = Path.Combine("TestResults", "Traces", $"{testName}.zip");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await context.Tracing.StopAsync(new TracingStopOptions
{
Path = path
});
}
/// <summary>
/// Waits for htmx to settle (no pending requests).
/// </summary>
public static async Task WaitForHtmxSettleAsync(IPage page, int timeoutMs = 5000)
{
await page.WaitForFunctionAsync(
"() => !document.querySelector('.htmx-request')",
new() { Timeout = timeoutMs });
}
/// <summary>
/// Waits for any htmx request to complete.
/// </summary>
public static async Task<IResponse> WaitForAnyHtmxRequestAsync(IPage page)
{
return await page.WaitForResponseAsync(r =>
r.Request.Headers.ContainsKey("hx-request") ||
r.Url.Contains("handler="));
}
}
Integration/Common/TestHelpers.cs
using AngleSharp.Html.Dom;
using AngleSharp.Dom;
namespace ChinookDashboard.Tests.Integration.Common;
public static class TestHelpers
{
/// <summary>
/// Extracts all OOB elements from a response document.
/// </summary>
public static IEnumerable<(IElement Element, string Target, string SwapType)>
GetOobUpdates(IHtmlDocument document)
{
var oobElements = document.QuerySelectorAll("[hx-swap-oob]");
foreach (var element in oobElements)
{
var oobValue = element.GetAttribute("hx-swap-oob") ?? "true";
var id = element.Id ?? "";
// Parse OOB value (e.g., "true", "innerHTML", "outerHTML:#target")
var parts = oobValue.Split(':');
var swapType = parts[0];
var target = parts.Length > 1 ? parts[1] : $"#{id}";
yield return (element, target, swapType);
}
}
/// <summary>
/// Verifies all form inputs have unique names.
/// </summary>
public static void AssertUniqueFormInputNames(IHtmlDocument document)
{
var inputs = document.QuerySelectorAll("input[name], select[name], textarea[name]");
var names = inputs.Select(i => i.GetAttribute("name")).Where(n => !string.IsNullOrEmpty(n));
var duplicates = names.GroupBy(n => n).Where(g => g.Count() > 1).Select(g => g.Key);
Assert.Empty(duplicates);
}
}
#
22.8.4 Continuous Integration
Configure GitHub Actions to run tests automatically.
.github/workflows/test.yml
name: Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
unit-and-integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run Unit Tests
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName~Unit" --logger "trx;LogFileName=unit-results.trx"
- name: Run Integration Tests
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName~Integration" --logger "trx;LogFileName=integration-results.trx"
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/TestResults/*.trx'
browser-tests:
runs-on: ubuntu-latest
needs: unit-and-integration-tests
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Install Playwright browsers
run: pwsh ChinookDashboard.Tests/bin/Release/net8.0/playwright.ps1 install --with-deps chromium
- name: Run Browser Tests
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName~Browser" --logger "trx;LogFileName=browser-results.trx"
env:
PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright
- name: Upload browser test results
uses: actions/upload-artifact@v4
if: always()
with:
name: browser-test-results
path: '**/TestResults/*.trx'
- name: Upload Playwright traces
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: '**/TestResults/Traces/'
- name: Upload screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: '**/TestResults/Screenshots/'
#
22.9 Summary
Testing htmx applications requires a multi-layered approach. Unit tests verify business logic, integration tests verify HTML structure and htmx attributes, and browser tests verify actual user interactions.
#
Testing Strategy
#
Key Testing Patterns
#
Test Coverage Checklist
Unit Tests Should Cover:
- All service methods (CRUD operations)
- View model computed properties
- Pagination calculations
- Helper method logic
Integration Tests Should Cover:
- Full page loads return complete HTML
- Partial responses don't include layout
- htmx attributes have correct values
- Response headers are set correctly
- OOB elements target correct IDs
- Forms have anti-forgery tokens
- Form fields match handler parameters
Browser Tests Should Cover:
- Search filters results without page reload
- Inline edit complete workflow
- Modal open/submit/close cycle
- Delete with confirmation
- OOB updates change multiple elements
- Keyboard shortcuts work
- Toast appears and auto-dismisses
- Error scenarios show feedback
#
Common Testing Pitfalls
Timing Issues:
- Always use explicit waits (
WaitForSelector,WaitForResponse) - Avoid arbitrary
WaitForTimeoutwhen possible - Add small buffers after dynamic operations
Flaky Tests:
- Use unique data for each test (GUIDs)
- Reset state between tests
- Don't depend on test execution order
Anti-Patterns:
- Testing implementation details instead of behavior
- Over-mocking (hiding real bugs)
- Ignoring error paths
- Skipping browser tests for "simple" features
#
Companion Code Files
ChinookDashboard.Tests/
├── ChinookDashboard.Tests.csproj
├── Unit/
│ ├── ServiceTestBase.cs
│ ├── Services/
│ │ └── ArtistServiceTests.cs
│ ├── Models/
│ │ ├── PaginatedListTests.cs
│ │ └── TrackSummaryTests.cs
│ └── Helpers/
│ ├── HtmxExtensionTests.cs
│ └── ToastHelperTests.cs
├── Integration/
│ ├── Fixtures/
│ │ └── ChinookTestFactory.cs
│ ├── Common/
│ │ ├── IntegrationTestBase.cs
│ │ ├── HttpClientHtmxExtensions.cs
│ │ ├── HtmlParsingHelper.cs
│ │ ├── HtmxAssertions.cs
│ │ └── TestHelpers.cs
│ ├── Artists/
│ │ ├── ArtistPageTests.cs
│ │ ├── ArtistPartialTests.cs
│ │ ├── ArtistEditFormTests.cs
│ │ ├── ArtistResponseHeaderTests.cs
│ │ └── ArtistOobTests.cs
│ └── Tracks/
│ └── TrackRowAttributeTests.cs
├── Browser/
│ ├── Fixtures/
│ │ └── PlaywrightFixture.cs
│ ├── Common/
│ │ ├── PlaywrightTestBase.cs
│ │ └── PlaywrightHelpers.cs
│ ├── Artists/
│ │ ├── ArtistSearchTests.cs
│ │ ├── InlineEditTests.cs
│ │ ├── DeleteWithOobTests.cs
│ │ └── CreateArtistModalTests.cs
│ ├── Hyperscript/
│ │ ├── TabSelectionTests.cs
│ │ ├── KeyboardNavigationTests.cs
│ │ ├── ToastAutoDismissTests.cs
│ │ └── EditFormFocusTests.cs
│ └── Errors/
│ ├── ServerErrorTests.cs
│ ├── FormValidationTests.cs
│ ├── NetworkErrorTests.cs
│ └── TimeoutTests.cs
└── TestData/
└── SeedData.cs
This chapter covered testing strategies for htmx applications from unit tests through browser automation. The patterns and tools presented here provide a foundation for maintaining quality as your application grows. Well-tested htmx applications give confidence that changes won't break existing functionality and that users will have a smooth, responsive experience.