SseAssertions.TUnit
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.
Scope: Test projects only. Not intended for production code.
Part of the DotNetAssertions family of assertion extensions for TUnit.
Table of contents
- Why this package
- Install
- Package layout
- Namespaces
- Quick start
- Wire-format syntax
- Entry points
- Failure diagnostics
- Cookbook
- Out of scope
- Design notes
- Stability intent (pre-1.0)
- Roadmap
- Family compatibility
- Pair with
- Contributing
- License
Why this package
Server-Sent Events is a small, well-defined wire format that turns up frequently in
test code: an HTTP endpoint streams event: tick\ndata: 1\n\n frames; a test wants to
assert "the endpoint emitted at least 3 tick events" or "the order-update with id
42 carried status shipped". Without a dedicated assertion library that means
writing a small ad-hoc parser per test, or reaching for a heavier framework whose
mental model doesn't match the wire format.
SseAssertions.TUnit ships a focused fluent surface for that case:
- A WHATWG / W3C-compliant frame parser (
SseFrameParser.Parse(string)) that producesIReadOnlyList<SseEvent>records. - A single TUnit
HasSseEvent("tick")chain on thestring,Stream, andHttpResponseMessagereceivers with theWithData(predicate)/WithDataParsedAs<T>(parse, predicate)/WithId(id)/WithRetryMillis(predicate)narrowers andAtLeast(n)/AtMost(n)/Exactly(n)terminators. - On the
StreamandHttpResponseMessagereceivers the same chain adds cancellation-bounded partial-buffer reads and default-onContent-Type: text/event-streamvalidation, so per-frame payload can be asserted against a live, open SSE response. - A public
SseFailureMessageextension point so consumer-authored typed SSE assertions produce failure messages in the same shape as the shipped surface.
No runtime reflection. No Microsoft.AspNetCore.* dependency. AOT-clean from
day one. The framework-agnostic SseAssertions core ships separately so
non-TUnit consumers can reuse the parser.
Install
# TUnit consumers install the adapter; the core is pulled transitively:
dotnet add package SseAssertions.TUnit
# Framework-agnostic consumers (rare in test projects) can pull the core directly:
dotnet add package SseAssertions
Requirements: TUnit 1.61.38 or later, .NET 10. AOT-compatible, trimmable.
Package layout
| Package | Purpose | Depends on |
|---|---|---|
SseAssertions |
Framework-agnostic core: SseEvent record, SseFrameParser, SseFailureMessage, SseCountComparison, SseFormat |
BCL only |
SseAssertions.TUnit |
TUnit adapter: HasSseEvent chain on string, Stream, and HttpResponseMessage, plus IsServerSentEventsStream() discriminator |
SseAssertions + TUnit.Assertions + TUnit.Core |
Install SseAssertions.TUnit for TUnit test projects; SseAssertions comes
transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not
shipped; they would reuse the SseAssertions core. Open a feature request if
you need one.
Namespaces
| Type / member | Namespace | Auto-imported? |
|---|---|---|
Fluent entry points (HasSseEvent, IsServerSentEventsStream) |
TUnit.Assertions.Extensions |
Yes (TUnit auto-imports) |
Core types (SseEvent, SseFrameParser, SseFailureMessage, SseCountComparison, SseFormat) |
SseAssertions |
No - needs using SseAssertions; |
Chain assertion class (SseHasEventAssertion) |
SseAssertions.TUnit |
No - generally invisible to consumers; only surfaces in failure-message types |
A GlobalUsings.cs in your test project:
global using SseAssertions;
global using SseAssertions.TUnit;
makes both namespaces available everywhere. The fluent entry points
HasSseEvent and IsServerSentEventsStream are auto-imported via
TUnit.Assertions.Extensions so they need no using of their own.
Quick start
Assert on an SSE wire-format string:
[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);
}
Assert on an HTTP response body:
[Test]
public async Task NotificationEndpoint_PublishesTickEveryTwoSeconds(CancellationToken ct)
{
using var response = await _client.GetAsync("/notifications/ticks?take=3", ct);
await Assert.That(response).HasSseEvent("tick", cancellationToken: ct).AtLeast(3);
}
Assert per-frame payload directly against the open response (no full materialization):
[Test]
public async Task OrderUpdates_StreamHasShippedEventForExpectedOrder(CancellationToken ct)
{
using var response = await _client.GetAsync("/orders/42/updates?take=5", ct);
await Assert.That(response)
.HasSseEvent("order-update", cancellationToken: ct)
.WithData(json => json.Contains("\"orderId\":42", StringComparison.Ordinal)
&& json.Contains("\"status\":\"shipped\"", StringComparison.Ordinal))
.AtLeast(1);
}
Wire-format syntax
Quick refresher of the WHATWG / W3C SSE wire format. The parser honors every rule described here.
- Field lines have the form
field-name: field-value. A single leading space after the colon is stripped per spec; subsequent spaces are preserved. - Recognized field names are
event,data,id, andretry. Unknown field names are ignored. - Comment lines start with
:and are ignored. They are typically used as keepalive heartbeats. data:lines accumulate within a frame, joined with\n(LF). A final trailing LF is stripped before dispatch.- A blank line dispatches the frame as one event. A frame without any
data:line is dropped per spec; the assertion library therefore never produces a frame with emptyEventNameand no observed data. - Default event name is
"message"when no explicitevent:directive appears in the frame.SseEvent.EventNameis non-nullable; the parser fills in the default. Practical consequence for test fixtures: adata: ...\n\nframe with noevent:directive matchesHasSseEvent("message"),HasFirstSseEvent("message"), andHasSseEventsInOrder("message"). This is per spec, not an assertion-library quirk: the WHATWG SSE algorithm dispatches unlabeled frames asevent: message. Test fixtures that emit unlabeled frames must assert against"message", notnull. retry:values must be non-negative ASCII digits; non-numeric values are ignored.retry:is a directive field, not a named event.HasSseRetryDirectiveFirstmatches the WHATWGretry:directive field, not anevent: retrynamed event. A stream that emitsevent: retryfollowed by adata:field but noretry:field line fails ("no retry directive was found"). The check is spec-strict and reads the wire-level field, not the dispatched event name.- An empty
data:line does not count as data. The standard ASP.NET Core SSE writer serializes a reconnection control frame asevent: retry, then an emptydata:line, thenretry: <ms>(the runtime fixes field order to event, data, id, retry).HasSseRetryDirectiveFirstignores emptydata:lines, so this control frame passes: theretry:directive is present and is the first event. Only a non-emptydata:value before the firstretry:fails. - Pin position and value in one read.
HasSseRetryDirectiveFirst(millis)asserts the leadingretry:directive's position and value together, for a forward-onlyHttpResponseMessagebody that cannot be read twice (the position-onlyHasSseRetryDirectiveFirst()and the value-onlyHasSseRetryDirective(millis)each consume the body, so they cannot both inspect the same response). - Line terminators:
\n,\r\n, and\rare all valid. - UTF-8 BOM at byte offset 0 is consumed and ignored. A BOM-like character appearing mid-stream is treated as a regular character of its containing field's value.
Entry points
| Receiver | Entry point | Returns | Chain methods |
|---|---|---|---|
string |
.HasSseEvent(eventName) |
SseHasEventAssertion (chain) |
WithData(Func<string, bool>), WithDataParsedAs<T>(Func<string, T>, Func<T, bool>), WithId(string), WithRetryMillis(Func<int?, bool>), AtLeast(int), AtMost(int), Exactly(int) |
string |
.IsServerSentEventsStream() |
flat - AssertionResult |
- |
Stream |
.HasSseEvent(eventName, cancellationToken) |
SseStreamHasEventAssertion (chain) |
WithData, WithDataParsedAs<T>, WithId, WithRetryMillis, AtLeast(int), AtMost(int), Exactly(int) |
HttpResponseMessage |
.HasSseEvent(eventName, strictContentType, cancellationToken) |
SseResponseHasEventAssertion (chain) |
WithData, WithDataParsedAs<T>, WithId, WithRetryMillis, AtLeast(int), AtMost(int), Exactly(int) |
HttpResponseMessage |
.HasSseContentType(strict) |
flat - AssertionResult |
- |
string |
.HasFirstSseEvent(eventName) |
flat - AssertionResult |
- |
Stream |
.HasFirstSseEvent(eventName, cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.HasFirstSseEvent(eventName, strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
string |
.HasSseEventsInOrder(eventNames) |
SseEventsInOrderAssertion (chain) |
WithStrictOrdering() |
Stream |
.HasSseEventsInOrder(eventNames, strictOrdering, cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.HasSseEventsInOrder(eventNames, strictOrdering, strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
string |
.HasSseRetryDirective(millis) |
flat - AssertionResult |
- |
Stream |
.HasSseRetryDirective(millis, cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.HasSseRetryDirective(millis, strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
string |
.HasSseRetryDirectiveFirst() |
flat - AssertionResult |
- |
Stream |
.HasSseRetryDirectiveFirst(cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.HasSseRetryDirectiveFirst(strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
string |
.HasSseRetryDirectiveFirst(millis) |
flat - AssertionResult |
- |
Stream |
.HasSseRetryDirectiveFirst(millis, cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.HasSseRetryDirectiveFirst(millis, strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
Stream |
.EndsCleanlyOnCancellation(cancellationToken) |
flat - Task<AssertionResult> |
- |
HttpResponseMessage |
.EndsCleanlyOnCancellation(strictContentType, cancellationToken) |
flat - Task<AssertionResult> |
- |
The same HasSseEvent(eventName) chain is available on all three receivers. On
the string receiver the body is already in memory; on the Stream and
HttpResponseMessage receivers the body read happens inside the assertion under
the supplied cancellation token, and the narrowers and count terminators apply to
the frames seen within that read.
Failure diagnostics
SseFailureMessage is the failure-message factory used by the shipped chain.
It is also public and intended as the extension point for consumer-authored
typed SSE assertions - see Cookbook pattern 4.
Missing event (the chain looked for "tick", none found):
to find at least one event of type "tick"
but observed: 0 events of type "tick" out of 2 total event(s) captured:
[0] event=ping data="alive"
[1] event=heartbeat data=""
Count mismatch (chain asked for at least 5 but observed 3):
to find at least 5 event(s) of type "tick"
but observed: 3 event(s) of type "tick"
Data-predicate did not match (chain found events of the right type but the data predicate rejected all of them):
to find at least one event of type "tick" whose Data satisfied the predicate
but observed 3 event(s) of that type; none matched:
data: "1"
data: "2"
data: "3"
Content-Type mismatch (HttpResponseMessage with strictContentType: true,
response carried application/json):
the response to have Content-Type "text/event-stream"
but got: application/json
Cancellation cut the read (the CancellationToken fired mid-body-read; the
partial buffer was parsed and asserted but did not satisfy the count):
to find at least 5 event(s) of type "tick"
but observed: 2 event(s) of type "tick"
... and a follow-up:
the read was canceled after 1247 byte(s); parsed 3 event(s) from the partial buffer
partial body excerpt: event: tick\ndata: 1\n\nevent: tick\ndata: 2\n\nevent: tick\nda...
Per-event Data is truncated at 80 characters in the failure list; body
excerpts (parse failures and cancellation excerpts) are truncated at 256
characters. Truncations use the U+2026 ellipsis (...).
Cookbook
Pattern 1: Assert events from a finite-output HTTP-response SSE stream
For SSE endpoints that emit a bounded number of events and close, the receiver
overload on HttpResponseMessage works end-to-end in one call:
[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);
}
Pattern 2: JSON-payload composition without a JsonAssertions reference
Per the family's cross-package references rule, this package does not depend on
JsonAssertions.TUnit. Compose the two at the consumer's call site by reading
the body to a string and writing a WithData predicate that uses your
JsonSerializerContext:
[Test]
public async Task OrderUpdates_StreamHasShippedEventForExpectedOrder(CancellationToken ct)
{
using var response = await _client.GetAsync("/orders/42/updates?take=5", ct);
var body = await response.Content.ReadAsStringAsync(ct);
await Assert.That(body)
.HasSseEvent("order-update")
.WithData(json =>
{
var update = JsonSerializer.Deserialize(json, MyJsonContext.Default.OrderEvent);
return update?.OrderId == 42 && update.Status == "shipped";
})
.AtLeast(1);
}
MyJsonContext is a consumer-defined [JsonSerializable(typeof(OrderEvent))]
context; deserialization stays AOT-clean.
Pattern 3: Polling with TUnit's built-in Eventually
For "the endpoint will emit a cache-ready event within ten seconds" without a
fixed take query parameter, compose with TUnit's Eventually:
[Test]
public async Task CacheWarmup_EmitsReadyEventWithinTimeout(CancellationToken ct)
{
await Assert.That(async () =>
{
using var response = await _client.GetAsync("/cache/status?take=1", ct);
return await response.Content.ReadAsStringAsync(ct);
})
.Eventually(body => Assert.That(body).HasSseEvent("cache-ready").AtLeast(1))
.WithinTimeBudget(TimeSpan.FromSeconds(10));
}
Pattern 4: Consumer-authored typed SSE assertions
SseFailureMessage is public so consumer-authored assertions can produce
failure messages that match this package's diagnostic style. Compose
SseFrameParser.Parse with one of the factory methods:
using SseAssertions;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Core;
public static class OrderEventAssertions
{
[GenerateAssertion]
public static AssertionResult HasOrderShippedFor(this string body, int orderId)
{
var events = SseFrameParser.Parse(body);
foreach (var evt in events)
{
if (!string.Equals(evt.EventName, "order-update", StringComparison.Ordinal))
{
continue;
}
var order = JsonSerializer.Deserialize(evt.Data, MyJsonContext.Default.OrderEvent);
if (order?.OrderId == orderId && order.Status == "shipped")
{
return AssertionResult.Passed;
}
}
return AssertionResult.Failed(SseFailureMessage.EventNotFound("order-update", events));
}
}
// Test:
await Assert.That(responseBody).HasOrderShippedFor(orderId: 42);
Pattern 5: Testing infinite-stream endpoints (with cancellation-bounded reads)
Production SSE endpoints typically stream indefinitely with heartbeats. The buffer-mode read works against bounded-output endpoints; for indefinite streams you have two approaches:
(a) Test-mode query parameter - design the endpoint with a finite-event mode:
// Production: GET /notifications/ticks -> infinite stream
// Test mode: GET /notifications/ticks?take=3 -> emit 3 events then close
(b) Cancellation-bounded read - let the test cancel after a known emission window. The chain captures the cancellation, parses whatever was buffered before the cut, and asserts against the partial result. No try/catch needed; the cancellation is part of the chain semantics:
[Test]
[CancelAfter(2_000)]
public async Task TickEndpoint_EmitsAtLeastTwoTicksInTwoSeconds(CancellationToken ct)
{
using var response = await _client.GetAsync("/notifications/ticks", ct);
await Assert.That(response).HasSseEvent("tick", cancellationToken: ct).AtLeast(2);
}
The streaming read consumes the full token window: it does not stop early once
the expectation is already satisfied, so set the token to your intended time
budget (the [CancelAfter(2_000)] above bounds the read to two seconds).
AtMost and Exactly necessarily read to the window to confirm no further
matching frames arrive; a satisfied AtLeast waits for it too.
Pattern (a) is the recommended approach for deterministic tests; pattern (b) suits timing-sensitive scenarios where the endpoint cannot be modified. True streaming async-enumerable mode is a candidate for a future release.
Out of scope
Read this before opening a feature request.
- Streaming async-enumerable mode. The assertion reads the entire response body
before parsing; this works against bounded-output endpoints (see Pattern
5(a))
and combines with cancellation for indefinite streams (Pattern 5(b)). A
true streaming
IAsyncEnumerable<SseEvent>mode is a candidate for a future release. OfType(name)chain method. Redundant withHasSseEvent(name).InAnyOrder()chain method. Set-semantics adds complexity for marginal benefit; the dominant pattern is order-insensitiveAtLeast(n).WithDataMatching(JsonPath). Cross-package coupling rule prohibits a directJsonAssertionsreference. Compose viaWithDataand a consumer-provided deserialize delegate at the call site.- Server-side SSE production helpers. This is an assertion library, not a producer.
- Reconnection / last-event-id replay logic. Consumer responsibility.
xUnit/NUnit/MSTestadapters. TUnit only.
Design notes
- One
HasSseEventchain across all three receivers. SSE assertions compose, so a chain (HasSseEvent(name).WithData(...).AtLeast(n)) stays linear where flat overloads would explode combinatorially. Same shape asLogAssertions'HasLogged().WithException<T>().AtLeast(n). TheStreamandHttpResponseMessagereceivers read the body inside the assertion'sCheckAsync, so the narrowers configure the matcher synchronously and the single async read happens once at evaluation time. Thestring,Stream, andHttpResponseMessagechains all delegate to the sharedSseEventMatcher, so their diagnostics are identical. minCountis.AtLeast(n). The streaming entry methods carry only what must precede the read (strictContentType,cancellationToken); the count is a terminator like every other receiver, which is why the pre-0.7.0HasSseEvent(eventName, minCount, ...)flat form was replaced.- Content-Type
text/event-streamvalidation is on by default. It catches the foot-gun where a test hits the wrong endpoint and gets HTML / JSON / a 500: without it the parser silently yields zero events and the failure is confusing. Opt out withstrictContentType: false. Comparison is case-insensitive (RFC 9110). - Caller-supplied deserialization, not
JsonTypeInfo<T>.WithDatatakes aFunc<string, bool>andWithDataParsedAs<T>takes aFunc<string, T>parse delegate, so any deserialization strategy stays at your call site. Neither forces aSystem.Text.Jsondependency or an adapter; per the family's cross-package rule the package takes noJsonAssertionsreference. Supply a source-generatedJsonTypeInfo<T>inside the delegate to keep parsing AOT-compatible. - Cancellation-bounded reads. The
Stream/HttpResponseMessageoverloads use anArrayPool<byte>-backed read loop rather thanReadAsStringAsync(which discards its partial buffer on cancellation); the loop parses whatever bytes arrived as best-effort SSE. Encoding comes from theContent-Typecharset, UTF-8 fallback.
Stability intent (pre-1.0)
This is a 0.x release and the public API may evolve.
- Additive changes (new entry points, new chain methods) ship in any patch without breaking ApiCompat.
- Breaking changes to existing signatures bump the minor version (0.X.0) and are called out in the CHANGELOG.
PackageValidationBaselineVersionpins to the previous shipped version so ApiCompat catches binary breaks at pack time;CompatibilitySuppressions.xmlrecords accepted differences.
The 1.0 milestone signals API stability.
Roadmap
- Streaming async-enumerable read of
HttpResponseMessagefor indefinite-stream endpoints; yieldsSseEventon demand.
Demand-driven; no fixed timeline.
Family compatibility
The nine assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, SnapshotAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, SseAssertions.TUnit, GrpcAssertions.TUnit, TracingAssertions.TUnit, and MetricsAssertions.TUnit: release independently and target the same .NET TFM at any moment (LTS-anchored, multi-target during STS support windows; see the TFM policy in CONVENTIONS.md for the rotation schedule). Mix versions freely. Each package ships under SemVer with EnablePackageValidation strict-mode ApiCompat against its previous baseline, so binary breaks within a version line are caught at pack time.
For per-package release notes:
- LogAssertions.TUnit CHANGELOG
- TimeAssertions.TUnit CHANGELOG
- SnapshotAssertions.TUnit CHANGELOG
- MathAssertions.TUnit CHANGELOG
- JsonAssertions.TUnit CHANGELOG
- SseAssertions.TUnit CHANGELOG
- GrpcAssertions.TUnit CHANGELOG
- TracingAssertions.TUnit CHANGELOG
- MetricsAssertions.TUnit CHANGELOG
Pair with
LogAssertions.TUnit: fluent log assertions overMicrosoft.Extensions.Logging.Testing.FakeLogCollector.TimeAssertions.TUnit:TimeProvider-aware time assertions and cross-cutting.WithinTimeBudget(...)chain methods.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. Coexists with Verify; covers the 80% case without coverage friction.MathAssertions.TUnit: tolerance-aware fluent assertions over numeric and geometric types (vectors, quaternions, matrices, planes, complex numbers, arrays).JsonAssertions.TUnit: fluent JSON assertions overSystem.Text.Json, HTTP response bodies (including RFC 7807 ProblemDetails), and source-generatedJsonSerializerContextregistration.GrpcAssertions.TUnit: fluent gRPC outcome assertions (ThrowsGrpcExceptionwithStatusCodeshorthands and detail refinements) plus theGrpcCallBuildertest-double helper.TracingAssertions.TUnit: fluent OpenTelemetry distributed-tracing (Activity/ span) assertions: operation name, tags, status, and parent/child and same-trace relationships, captured via a rawActivityListenerwith no OpenTelemetry SDK dependency.MetricsAssertions.TUnit: fluent assertions overSystem.Diagnostics.Metricsinstruments (counters, histograms, gauges), built onMetricCollector.
Contributing
Issues and pull requests welcome. Before opening a PR:
- Run
dotnet buildanddotnet testlocally; the CI pipeline enforces the same quality bar (zero warnings as errors, 90% line / 90% branch coverage minimum). - Match the existing code style (
.editorconfigis authoritative;dotnet formatcovers formatting). - For new assertions, include a test for both the happy path and a representative failure case.
For larger ideas, open a Discussion first to align on direction before investing implementation time.
See CONTRIBUTING.md for the full PR review checklist, and CONVENTIONS.md for the family-wide code conventions shared across LogAssertions.TUnit, SnapshotAssertions.TUnit, TimeAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, and this repo.
License
MIT. Copyright (c) 2026 John Verheij.