SseAssertions.TUnit
0.7.0
Prefix Reserved
dotnet add package SseAssertions.TUnit --version 0.7.0
NuGet\Install-Package SseAssertions.TUnit -Version 0.7.0
<PackageReference Include="SseAssertions.TUnit" Version="0.7.0" />
<PackageVersion Include="SseAssertions.TUnit" Version="0.7.0" />
<PackageReference Include="SseAssertions.TUnit" />
paket add SseAssertions.TUnit --version 0.7.0
#r "nuget: SseAssertions.TUnit, 0.7.0"
#:package SseAssertions.TUnit@0.7.0
#addin nuget:?package=SseAssertions.TUnit&version=0.7.0
#tool nuget:?package=SseAssertions.TUnit&version=0.7.0
SseAssertions.TUnit
Scope: Test projects only. Not intended for production code.
TUnit-native Server-Sent Events (SSE) assertions for .NET. Fluent entry points over TUnit's Assert.That(...) pipeline for asserting on SSE event streams from HTTP response bodies, streams, and strings. AOT-compatible, trimmable, no runtime reflection in the assertion path.
Full documentation and roadmap: github.com/JohnVerheij/SseAssertions.TUnit
What ships
| Entry point | Receiver | Shape |
|---|---|---|
HasSseEvent(eventName) |
string |
Chain with WithData(predicate), WithDataParsedAs<T>(parse, predicate), WithId(id), WithRetryMillis(predicate), AtLeast(n), AtMost(n), Exactly(n) |
HasSseEvent(eventName, cancellationToken) |
Stream |
Same chain; cancellation-bounded partial-buffer reads (v0.7.0+) |
HasSseEvent(eventName, strictContentType, cancellationToken) |
HttpResponseMessage |
Same chain; default-on Content-Type: text/event-stream validation (v0.7.0+) |
IsServerSentEventsStream() |
string |
Lightweight discriminator. |
HasSseContentType(strict) |
HttpResponseMessage |
Header-only discriminator (no body read). strict: false (default) matches text/event-stream with any parameters; strict: true requires the bare media type with no parameters. |
HasFirstSseEvent(eventName) |
string |
Asserts the first parsed frame's event: name. Unlabeled frames match HasFirstSseEvent("message") per the WHATWG default. |
HasFirstSseEvent(eventName, cancellationToken) |
Stream |
Async; reads to end then asserts first frame. |
HasFirstSseEvent(eventName, strictContentType, cancellationToken) |
HttpResponseMessage |
Async; validates Content-Type (default-on) then asserts first frame. |
HasSseEventsInOrder(eventNames) |
string |
Chain with .WithStrictOrdering(). Default permits gaps between named events; strict mode requires contiguous appearance. |
HasSseEventsInOrder(eventNames, strictOrdering, cancellationToken) |
Stream |
Async flat form. strictOrdering: true requires contiguous. |
HasSseEventsInOrder(eventNames, strictOrdering, strictContentType, cancellationToken) |
HttpResponseMessage |
Async flat form with Content-Type validation (default-on). |
HasSseRetryDirective(millis) |
string |
Asserts a retry: directive is present. millis: null accepts any value; millis: <n> requires exact match. |
HasSseRetryDirective(millis, cancellationToken) |
Stream |
Async; same shape. |
HasSseRetryDirective(millis, strictContentType, cancellationToken) |
HttpResponseMessage |
Async; validates Content-Type (default-on) then inspects retry directives. |
HasSseRetryDirectiveFirst() |
string / Stream / HttpResponseMessage |
Asserts a retry: directive precedes the first non-empty data: field, checked at the wire-field level. An empty data: line carries no payload and does not count, so the standard ASP.NET Core control frame (event: retry + empty data: + retry: <ms>) passes. Stream / HttpResponseMessage forms take cancellationToken; HttpResponseMessage takes strictContentType (default-on). Matches the retry: directive field, not an event: retry named event: a stream emitting event: retry + data: with no retry: field fails. (v0.3.0+; empty-data: handling v0.4.1+) |
EndsCleanlyOnCancellation(cancellationToken) |
Stream / HttpResponseMessage |
Asserts a canceled read tears down via cooperative cancellation rather than a transport exception (IOException / HttpRequestException). HttpResponseMessage form takes strictContentType (default-on) and reads the body via ReadAsStreamAsync. (Stream v0.3.0+; HttpResponseMessage v0.4.0+) |
The HasSseEvent(eventName) chain narrows by data payload (WithData(Func<string, bool>), or WithDataParsedAs<T>(Func<string, T> parse, Func<T, bool> predicate) for a caller-supplied reflection-free deserializer), by the per-event id: directive (WithId(string)), or by the per-event retry: directive (WithRetryMillis(Func<int?, bool>)), then terminates with AtLeast / AtMost / Exactly count assertions. The same chain is available on all three receivers; on Stream and HttpResponseMessage the body read happens inside the assertion under the supplied cancellation token.
Install
dotnet add package SseAssertions.TUnit
The framework-agnostic SseAssertions core (defining the SseEvent public record, SseFrameParser, and SseFailureMessage factories) comes transitively.
Requirements: TUnit 1.54.0 or later, .NET 10. AOT-compatible, trimmable, no runtime reflection in the assertion path.
Quick start
[Test]
public async Task TickEndpoint_EmitsThreeTicks()
{
const string body = "event: tick\ndata: 1\n\nevent: tick\ndata: 2\n\nevent: tick\ndata: 3\n\n";
await Assert.That(body).HasSseEvent("tick").Exactly(3);
}
[Test]
public async Task NotificationEndpoint_PublishesAtLeastThreeTicks(CancellationToken ct)
{
using var response = await _client.GetAsync("/notifications/ticks?take=3", ct);
await Assert.That(response).HasSseEvent("tick", cancellationToken: ct).AtLeast(3);
}
See the full README for the Wire-format syntax reference, Failure diagnostics catalog, Cookbook (5 patterns), Design notes, and Out-of-scope caveats.
License
MIT. Copyright (c) 2026 John Verheij.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- SseAssertions (>= 0.7.0)
- TUnit.Assertions (>= 1.54.0)
- TUnit.Core (>= 1.54.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
View the rendered release notes: https://github.com/JohnVerheij/SseAssertions.TUnit/releases/tag/v0.7.0
Minor release. The frame narrowers that the `string` receiver already had (`WithData`, `WithDataParsedAs<T>`, `WithId`, `WithRetryMillis`) now reach the `Stream` and `HttpResponseMessage` receivers, which is the realistic shape for testing a live SSE endpoint: subscribe to an open response, drive activity, and assert per-frame payload under a cancellation-bounded partial read. Achieving this with one coherent `HasSseEvent(eventName)` surface across all three receivers means the flat `HasSseEvent(eventName, minCount, ...)` methods are replaced by the chainable form, which is a breaking change.
### BREAKING
- **The flat `HasSseEvent(eventName, minCount, ...)` methods on `Stream` and `HttpResponseMessage` are replaced by a chainable `HasSseEvent(eventName)`** matching the `string` receiver. The minimum-count argument moves to the `.AtLeast(n)` terminator, and the narrowers and other terminators (`AtMost`, `Exactly`) are now available on the streaming receivers.
- Migration: `HasSseEvent("x", minCount: 2)` becomes `HasSseEvent("x").AtLeast(2)`; `HasSseEvent("x")` (default minimum of 1) is unchanged. The `strictContentType` and `cancellationToken` arguments stay on the entry method (`HasSseEvent("x", strictContentType: false, cancellationToken: ct)`).
- **A null `Stream` / `HttpResponseMessage` receiver now fails the assertion** ("the receiver was null") instead of throwing `ArgumentNullException`, matching the long-standing behavior of the `string` receiver. A null `eventName` still throws `ArgumentNullException`, and a negative count still throws `ArgumentOutOfRangeException` (now from the terminator).
### Added
- **Frame narrowers on the `Stream` and `HttpResponseMessage` receivers.** `WithData`, `WithDataParsedAs<T>`, `WithId`, and `WithRetryMillis` constrain which frames count within the cancellation-bounded read, producing the same per-narrower diagnostics as the `string` chain. `WithDataParsedAs<T>` takes a caller-supplied (source-generated) parser so the assertion stays AOT-compatible; a throwing parser fails naming the deserializer exception and the offending data. When the read is cancellation-truncated and the expectation is unmet, the cancellation-cut diagnostic still takes precedence over a count or narrower miss. All three receivers share one internal matching engine, so their diagnostics are identical. No public surface was added to the `SseAssertions` core.
### Fixed
- **A foreign cancellation during a streaming read is no longer reported as a cancellation-cut.** The shared response-read helper caught every `OperationCanceledException` as a caller-driven partial read; an `OperationCanceledException` from a different source (for example an `HttpClient` timeout) was therefore masked by the cancellation-cut diagnostic. It is now captured as a partial read only when the supplied token is the one that fired, otherwise it propagates, matching the distinction `EndsCleanlyOnCancellation` already made in 0.5.1. This applies to every `HttpResponseMessage` read path (`HasSseEvent`, `HasFirstSseEvent`, `HasSseEventsInOrder`, `HasSseRetryDirective`, `HasSseRetryDirectiveFirst`).
- **Stream acquisition no longer pre-empts the cancellation-cut diagnostic.** The `HttpResponseMessage` read paths passed the cancellation token to `ReadAsStreamAsync`, so a token that fired during stream acquisition threw out of the assertion instead of surfacing as the cancellation-cut diagnostic. Acquisition is now uncancellable; the token bounds only the body read, where cancellation is captured.