Swap.Testing 0.3.0

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Swap.Testing" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Swap.Testing" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Swap.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Swap.Testing --version 0.3.0
                    
#r "nuget: Swap.Testing, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Swap.Testing@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Swap.Testing&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Swap.Testing&version=0.3.0
                    
Install as a Cake Tool

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-Redirect via FollowHxRedirectAsync
  • โœ… 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 snapshot
  • todo-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

  1. Use Test Fixtures - Reuse HtmxTestFixture<TProgram> across tests with IClassFixture<T>
  2. Test HTMX Attributes - Verify your partials have correct hx-get, hx-post, hx-target, etc.
  3. Assert Partial Views - Use AssertPartialViewAsync() to ensure HTMX endpoints don't return full pages
  4. Chain Assertions - Leverage the fluent API for readable, maintainable tests
  5. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0 33 12/10/2025
0.4.2 389 11/20/2025
0.4.1 379 11/20/2025
0.4.0 267 11/16/2025
0.3.0 193 11/3/2025
0.2.0-dev 118 11/1/2025
0.1.0 185 10/30/2025