Swap.Testing
0.3.0
See the version list below for details.
dotnet add package Swap.Testing --version 0.3.0
NuGet\Install-Package Swap.Testing -Version 0.3.0
<PackageReference Include="Swap.Testing" Version="0.3.0" />
<PackageVersion Include="Swap.Testing" Version="0.3.0" />
<PackageReference Include="Swap.Testing" />
paket add Swap.Testing --version 0.3.0
#r "nuget: Swap.Testing, 0.3.0"
#:package Swap.Testing@0.3.0
#addin nuget:?package=Swap.Testing&version=0.3.0
#tool nuget:?package=Swap.Testing&version=0.3.0
Swap.Testing
A fluent testing framework for ASP.NET Core applications using HTMX, designed to make testing partial views and HTMX interactions delightful.
Features
- ๐ Fluent API - Chainable assertion methods for readable tests
- ๐ฏ HTMX-Aware - Built-in support for HX-Request headers and HTMX attributes
- ๐ HTML Parsing - Query and assert on HTML structure using CSS selectors
- โก Partial View Testing - Verify your HTMX endpoints return proper partials
- ๐งช Integration Testing - Built on ASP.NET Core's WebApplicationFactory
- ๐ Form Submission Helper - Submit forms from a prior response via
SubmitFormAsync - ๐ Follow Redirects - One-liner to follow
HX-RedirectviaFollowHxRedirectAsync - โ Validation Assertions - Assert summary or field-level validation errors
- ๐งฝ Snapshot Scrubbers - Replace GUIDs/timestamps/anti-forgery tokens for stable snapshots
Installation
dotnet add package Swap.Testing
Quick Start
1. Create a Test Fixture
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");
}
}
2. Test HTMX Partials
[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");
}
3. Test HTML Structure
[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");
}
4. Test POST Requests
[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/");
}
API Reference
HtmxTestClient<TProgram>
The main client for making requests to your application.
HTTP Methods
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)
HTMX Methods
// 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)
Configuration
HtmxTestClient<TProgram> WithHeader(string name, string value)
HtmxTestClient<TProgram> AsHtmxRequest()
HtmxTestResponse
Fluent assertion methods for HTTP responses.
Status Assertions
HtmxTestResponse AssertStatus(HttpStatusCode expectedStatus)
HtmxTestResponse AssertSuccess() // 2xx status codes
Content Assertions
Task<HtmxTestResponse> AssertContainsAsync(string expectedText)
Task<HtmxTestResponse> AssertDoesNotContainAsync(string unexpectedText)
Header Assertions
HtmxTestResponse AssertHeader(string headerName, string? expectedValue = null)
HTML Element Assertions
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)
HTMX Attribute Assertions
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)
Partial View Assertions
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)
Custom Assertions
Task<HtmxTestResponse> AssertAsync(Action<IHtmlDocument> assertion)
Task<HtmxTestResponse> AssertAsync(Func<IHtmlDocument, Task> assertion)
HTMX Header Assertions
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)
Snapshot Testing
Validation Assertions
Task<HtmxTestResponse> AssertHasValidationErrorsAsync()
Task<HtmxTestResponse> AssertFieldValidationErrorAsync(string fieldName, string? messageContains = null)
Task<HtmxTestResponse> AssertNoValidationErrorsAsync()
OOB Convenience
Task<HtmxTestResponse> AssertOutOfBandAsync(string cssSelector, string? expectedContains = null)
Task<HtmxTestResponse> AssertMatchesSnapshotAsync(string snapshotName, string? snapshotDirectory = null, bool? updateSnapshots = null)
Advanced Usage
Custom WebApplicationFactory Configuration
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();
}
Testing HTMX Triggers and Targets
[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>");
}
Complex HTML Assertions
[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]"));
}
});
}
Snapshot Testing
Submitting forms from a prior response
// 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");
Following HX-Redirect
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();
Asserting validation errors
Snapshot scrubbers
HX-Location JSON example
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:
- GUIDs โ [GUID]
- ISO date/time strings โ [DATETIME]
- Anti-forgery token values โ [TOKEN]
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_MatchesSnapshot
Snapshot 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 { }
- Keep your test files in a separate test project (e.g., MyApp.Tests) and exclude any Tests/** from your web app csproj:
<ItemGroup>
<Compile Remove="Tests\**\*.cs" />
<None Include="Tests\**\*.cs" />
</ItemGroup>
Best Practices
- Use Test Fixtures - Reuse
HtmxTestFixture<TProgram>across tests withIClassFixture<T> - Test HTMX Attributes - Verify your partials have correct hx-get, hx-post, hx-target, etc.
- Assert Partial Views - Use
AssertPartialViewAsync()to ensure HTMX endpoints don't return full pages - Chain Assertions - Leverage the fluent API for readable, maintainable tests
- Use CSS Selectors - Query HTML with specific, stable selectors
Philosophy
Swap.Testing is built with minimal external dependencies, focusing on:
- Quality over quantity - Every feature is well-crafted
- Developer experience - Fluent, intuitive API that reads like documentation
- HTMX-first - Designed specifically for testing hypermedia-driven applications
- Zero magic - Clear, explicit behavior
License
MIT
Contributing
Contributions welcome! Please ensure tests pass and maintain the coding style.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- AngleSharp (>= 1.1.2)
- Microsoft.AspNetCore.Mvc.Testing (>= 9.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.