Testing helpers for HTMX-powered ASP.NET Core applications. Fluent API for testing partial views, HTMX attributes, HX headers, and HTML responses.
$ dotnet add package Swap.TestingA fluent testing framework for ASP.NET Core applications using HTMX, designed to make testing partial views and HTMX interactions delightful.
SubmitFormAsyncHX-Redirect via FollowHxRedirectAsyncdotnet add package Swap.Testing
using Swap.Testing;
using Xunit;
namespace MyApp.Tests;
public class HomeControllerTests : IClassFixture<HtmxTestFixture<Program>>
{
private readonly HtmxTestClient<Program> _client;
public HomeControllerTests(HtmxTestFixture<Program> fixture)
{
_client = fixture.Client;
}
[Fact]
public async Task Index_ReturnsSuccessAndCorrectContent()
{
// Act
var response = await _client.GetAsync("/");
// Assert
response.AssertSuccess();
await response.AssertContainsAsync("Welcome to My App");
}
}
[Fact]
public async Task GetTodoPartial_ReturnsPartialWithHtmxAttributes()
{
// Act - Make an HTMX request
var response = await _client.HtmxGetAsync("/todos/1/edit");
// Assert - Verify it's a partial and has HTMX attributes
response.AssertSuccess();
await response.AssertPartialViewAsync();
await response.AssertHxPostAsync("form", "/todos/1");
await response.AssertHxTargetAsync("form", "#todo-1");
await response.AssertHxSwapAsync("form", "outerHTML");
}
[Fact]
public async Task TodoList_DisplaysAllTodos()
{
// Act
var response = await _client.GetAsync("/todos");
// Assert - Query HTML structure
response.AssertSuccess();
await response.AssertElementCountAsync(".todo-item", 3);
await response.AssertElementTextAsync("h1", "My Todos");
await response.AssertElementExistsAsync("#add-todo-button");
}[Fact]
public async Task CreateTodo_WithHtmx_ReturnsNewTodoPartial()
{
// Arrange
var formData = new Dictionary<string, string>
{
["title"] = "Buy groceries",
["completed"] = "false"
};
// Act - POST as HTMX request
var response = await _client.HtmxPostAsync("/todos", formData);
// Assert
response.AssertStatus(HttpStatusCode.Created);
await response.AssertPartialViewAsync();
await response.AssertContainsAsync("Buy groceries");
await response.AssertHxGetAsync(".edit-button", "/todos/");
}The main client for making requests to your application.
Task<HtmxTestResponse> GetAsync(string path)
Task<HtmxTestResponse> PostAsync(string path, Dictionary<string, string>? formData = null)
Task<HtmxTestResponse> PutAsync(string path, Dictionary<string, string>? formData = null)
Task<HtmxTestResponse> DeleteAsync(string path)// Automatically adds HX-Request: true header
Task<HtmxTestResponse> HtmxGetAsync(string path, string? target = null, string? trigger = null)
Task<HtmxTestResponse> HtmxPostAsync(string path, Dictionary<string, string>? formData = null, string? target = null, string? trigger = null)
Task<HtmxTestResponse> HtmxPutAsync(string path, Dictionary<string, string>? formData = null, string? target = null, string? trigger = null)
Task<HtmxTestResponse> HtmxDeleteAsync(string path, string? target = null, string? trigger = null)
// Helpers
Task<HtmxTestResponse> SubmitFormAsync(HtmxTestResponse response, string formSelector, Dictionary<string,string>? overrides = null, string? target = null, string? trigger = null)
Task<HtmxTestResponse> FollowHxRedirectAsync(HtmxTestResponse response, string? target = null, string? trigger = null)HtmxTestClient<TProgram> WithHeader(string name, string value)
HtmxTestClient<TProgram> AsHtmxRequest()Fluent assertion methods for HTTP responses.
HtmxTestResponse AssertStatus(HttpStatusCode expectedStatus)
HtmxTestResponse AssertSuccess() // 2xx status codesTask<HtmxTestResponse> AssertContainsAsync(string expectedText)
Task<HtmxTestResponse> AssertDoesNotContainAsync(string unexpectedText)HtmxTestResponse AssertHeader(string headerName, string? expectedValue = null)Task<HtmxTestResponse> AssertElementExistsAsync(string cssSelector)
Task<HtmxTestResponse> AssertElementNotExistsAsync(string cssSelector)
Task<HtmxTestResponse> AssertElementCountAsync(string cssSelector, int expectedCount)
Task<HtmxTestResponse> AssertElementTextAsync(string cssSelector, string expectedText)
Task<HtmxTestResponse> AssertHasCssClassAsync(string cssSelector, string className)
Task<HtmxTestResponse> AssertAttributeContainsAsync(string cssSelector, string attribute, string expectedSubstring)Task<HtmxTestResponse> AssertHxGetAsync(string cssSelector, string? expectedUrl = null)
Task<HtmxTestResponse> AssertHxPostAsync(string cssSelector, string? expectedUrl = null)
Task<HtmxTestResponse> AssertHxTargetAsync(string cssSelector, string? expectedTarget = null)
Task<HtmxTestResponse> AssertHxSwapAsync(string cssSelector, string? expectedSwap = null)
Task<HtmxTestResponse> AssertHxTriggerAsync(string cssSelector, string? expectedTrigger = null)
Task<HtmxTestResponse> AssertHxSwapOobAsync(string cssSelector, string? expectedValue = null)
Task<HtmxTestResponse> AssertHxAttributeAsync(string cssSelector, string attribute, string? expectedValue = null)Task<HtmxTestResponse> AssertPartialViewAsync() // Verifies no <html> or <body> tags in raw content
Task<HtmxTestResponse> AssertAntiForgeryTokenAsync(string formSelector = "form")
Task<HtmxTestResponse> AssertPartialRootIdAsync(string expectedId)
Task<HtmxTestResponse> AssertPartialRootMatchesAsync(string cssSelector)Task<HtmxTestResponse> AssertAsync(Action<IHtmlDocument> assertion)
Task<HtmxTestResponse> AssertAsync(Func<IHtmlDocument, Task> assertion)HtmxTestResponse AssertHxRedirect(string expectedUrl)
HtmxTestResponse AssertHxPushUrl(string? expectedValue = null) // true or URL
HtmxTestResponse AssertHxPushUrlTrue()
HtmxTestResponse AssertHxPushUrlFalse()
HtmxTestResponse AssertHxPushUrlUrl(string expectedUrl)
HtmxTestResponse AssertHxReswap(string? expectedValue = null)
HtmxTestResponse AssertHxRetarget(string? expectedValue = null)
HtmxTestResponse AssertHxRefresh(bool? expected = null) // presence or explicit
HtmxTestResponse AssertHxTriggerHeaderContains(string substring)
HtmxTestResponse AssertHxLocationContains(string substring) // hx-location JSON contains
// HX-Location JSON helpers
JsonDocument? GetHxLocationJson()
HtmxTestResponse AssertHxLocationFieldEquals(string fieldName, string expected)
HtmxTestResponse AssertHxLocationFieldContains(string fieldName, string expectedSubstring)
// HX-Trigger typed helpers (also support HX-Trigger-After-Swap / HX-Trigger-After-Settle via headerName)
string? GetHxTriggerRaw(string headerName = "HX-Trigger")
JsonDocument? GetHxTriggerJson(string headerName = "HX-Trigger")
IEnumerable<string> GetHxTriggerEventNames(string headerName = "HX-Trigger")
HtmxTestResponse AssertHxTriggered(string eventName, string headerName = "HX-Trigger")
HtmxTestResponse AssertHxTriggeredAfterSwap(string eventName)
HtmxTestResponse AssertHxTriggeredAfterSettle(string eventName)
HtmxTestResponse AssertHxTriggerFieldEquals(string eventName, string fieldName, string expected, string headerName = "HX-Trigger")
HtmxTestResponse AssertHxTriggerFieldContains(string eventName, string fieldName, string expectedSubstring, string headerName = "HX-Trigger")
HtmxTestResponse AssertHxTriggerAfterSwapFieldEquals(string eventName, string fieldName, string expected)
HtmxTestResponse AssertHxTriggerAfterSettleFieldEquals(string eventName, string fieldName, string expected)
HtmxTestResponse AssertHxTriggerAfterSwapFieldContains(string eventName, string fieldName, string expectedSubstring)
HtmxTestResponse AssertHxTriggerAfterSettleFieldContains(string eventName, string fieldName, string expectedSubstring)Task<HtmxTestResponse> AssertHasValidationErrorsAsync()
Task<HtmxTestResponse> AssertFieldValidationErrorAsync(string fieldName, string? messageContains = null)
Task<HtmxTestResponse> AssertNoValidationErrorsAsync()Task<HtmxTestResponse> AssertOutOfBandAsync(string cssSelector, string? expectedContains = null)Task<HtmxTestResponse> AssertMatchesSnapshotAsync(string snapshotName, string? snapshotDirectory = null, bool? updateSnapshots = null)public class CustomTestFixture : IDisposable
{
public HtmxTestClient<Program> Client { get; }
private readonly WebApplicationFactory<Program> _factory;
public CustomTestFixture()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Override services for testing
services.AddScoped<IMyService, MockMyService>();
});
});
Client = new HtmxTestClient<Program>(_factory);
}
public void Dispose() => _factory?.Dispose();
}[Fact]
public async Task DeleteTodo_ReturnsEmptyWithSwapOutOfBand()
{
var response = await _client
.AsHtmxRequest()
.WithHeader("HX-Target", "#todo-5")
.DeleteAsync("/todos/5");
response.AssertSuccess();
response.AssertHeader("HX-Trigger", "todoDeleted");
await response.AssertContainsAsync("<div id=\"todo-5\"></div>");
}[Fact]
public async Task TodoList_HasCorrectStructure()
{
var response = await _client.GetAsync("/todos");
await response.AssertAsync(async doc =>
{
var todos = doc.QuerySelectorAll(".todo-item");
Assert.Equal(5, todos.Length);
foreach (var todo in todos)
{
Assert.NotNull(todo.QuerySelector(".todo-title"));
Assert.NotNull(todo.QuerySelector("button[hx-delete]"));
}
});
}// GET the form as an HTMX partial
var getResponse = await _client.HtmxGetAsync("/posts/create");
getResponse.AssertSuccess();
await getResponse.AssertPartialViewAsync();
// Submit the form with overrides
var postResponse = await _client.SubmitFormAsync(getResponse, "form", new Dictionary<string, string>
{
["Title"] = "Hello",
["Body"] = "World",
["PublishedAt"] = DateTime.UtcNow.ToString("O"),
["AuthorId"] = "1"
});
postResponse.AssertSuccess();
postResponse.AssertHxTriggerHeaderContains("refreshPostList");var resp = await _client.HtmxPostAsync("/account/login", credentials);
resp.AssertSuccess();
// If server sets HX-Redirect: /dashboard, follow it
var redirectResp = await _client.FollowHxRedirectAsync(resp);
redirectResp.AssertSuccess();var response = await _client.HtmxPostAsync("/posts/apply-filter", new(){ ["authorId"] = "1" });
// Server returns: HX-Location: {"path":"/posts","target":"#post-list","swap":"innerHTML"}
response.AssertHxLocationFieldEquals("path", "/posts");
response.AssertHxLocationFieldContains("target", "#post-list");Built-in scrubbers make snapshots stable by replacing volatile values:
They are enabled by default. Toggle with environment variable SNAPSHOT_SCRUBBERS_DEFAULT (true/false). You can also customize programmatically:
// Disable defaults
SnapshotManager.UseDefaultScrubbers(false);
// Add your own scrubber
SnapshotManager.AddScrubber(content => content.Replace("8.99", "[PRICE]"));
// Remove custom scrubbers
SnapshotManager.ClearScrubbers();var invalid = await _client.HtmxPostAsync("/posts/create", new Dictionary<string,string>
{
["Title"] = "", // required
["AuthorId"] = "1",
["PublishedAt"] = DateTime.UtcNow.ToString("O")
});
invalid.AssertSuccess()
.AssertHxRetarget("#modal-container")
.AssertHxReswap("innerHTML");
await invalid.AssertHasValidationErrorsAsync();
await invalid.AssertFieldValidationErrorAsync("Title");Snapshot testing captures the HTML output and compares it on future runs to detect unintended changes.
[Fact]
public async Task TodoList_MatchesSnapshot()
{
// Act
var response = await _client.HtmxGetAsync("/todos");
// Assert - Compare against saved snapshot
response.AssertSuccess();
await response.AssertMatchesSnapshotAsync("todo-list");
}Update snapshots when you intentionally change HTML:
# Set environment variable to update all snapshots
UPDATE_SNAPSHOTS=true dotnet test
# Or update specific test
UPDATE_SNAPSHOTS=true dotnet test --filter TodoList_MatchesSnapshotSnapshot files are saved in __snapshots__/ directory:
todo-list.html - Expected snapshottodo-list.diff.html - Created when mismatch occurs (actual content)// Custom snapshot directory
await response.AssertMatchesSnapshotAsync(
"todo-list",
snapshotDirectory: "Tests/__snapshots__");
// Force update in code (not recommended)
await response.AssertMatchesSnapshotAsync(
"todo-list",
updateSnapshots: true);await response.AssertAsync(async doc => { var todos = doc.QuerySelectorAll(".todo-item"); Assert.Equal(5, todos.Length);
foreach (var todo in todos)
{
Assert.NotNull(todo.QuerySelector(".todo-title"));
Assert.NotNull(todo.QuerySelector("button[hx-delete]"));
}
});
## Test Project Setup Tips
- Expose your Program class so WebApplicationFactory can find it:
```csharp
// At the end of Program.cs in your web app
public partial class Program { }
<ItemGroup>
<Compile Remove="Tests\**\*.cs" />
<None Include="Tests\**\*.cs" />
<!-- exclude any demo-only seeders not present in your model -->
<!-- <Compile Remove="Data\Seeders\SomeDemoSeeder.cs" /> -->
<!-- <None Include="Data\Seeders\SomeDemoSeeder.cs" /> -->
</ItemGroup>HtmxTestFixture<TProgram> across tests with IClassFixture<T>AssertPartialViewAsync() to ensure HTMX endpoints don't return full pagesSwap.Testing is built with minimal external dependencies, focusing on:
MIT
Contributions welcome! Please ensure tests pass and maintain the coding style.