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. For a live endpoint, SseStream.OpenAsync returns once the server's first frame has arrived (it has subscribed), so a test can trigger events and assert on them without a sleep; the event assertions read only until their outcome is decided. 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.77.0 or later, .NET 10. AOT-compatible, trimmable.
Package layout
| Package | Purpose | Depends on |
|---|---|---|
SseAssertions |
Framework-agnostic core: SseStream (connect to a live endpoint), SseEventReader (read events as they arrive), SseEvent record, SseFrameParser, SseFailureMessage, SseCountComparison, SseFormat |
BCL only |
SseAssertions.TUnit |
TUnit adapter: HasSseEvent and HasSseEventIds chains on string, Stream, and HttpResponseMessage, plus first-event, in-order, retry-directive, and IsServerSentEventsStream() checks |
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, HasSseEventIds, IsServerSentEventsStream, ...) |
TUnit.Assertions.Extensions |
Yes (TUnit auto-imports) |
Core types (SseStream, SseEventReader, 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);
}
Connect to a live endpoint, trigger, assert:
[Test]
public async Task Cycle_PublishesPredictionThenCompletion(CancellationToken ct)
{
// Returns once the endpoint has written its first frame: it has subscribed, so nothing is missed.
await using var sse = await SseStream.OpenAsync(_client, new Uri("/cycles/events", UriKind.Relative), ct);
await _client.PostAsync("/cycles/start", content: null, ct);
// Each assertion reads only until it is decided, and the next one continues from there.
await Assert.That(sse).HasSseEvent("prediction", ct).WithId(id => id is not null);
await Assert.That(sse).HasSseEvent("cycleCompleted", ct);
}
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), WithId(Func<string?, bool>), WithRetryMillis(Func<int?, bool>), Until(string), AtLeast(int), AtMost(int), Exactly(int) |
string |
.IsServerSentEventsStream() |
flat - AssertionResult |
- |
Stream (incl. SseStream) |
.HasSseEvent(eventName, cancellationToken) |
SseStreamHasEventAssertion (chain) |
WithData, WithDataParsedAs<T>, WithId (both), WithRetryMillis, Until, AtLeast(int), AtMost(int), Exactly(int) |
HttpResponseMessage |
.HasSseEvent(eventName, cancellationToken) or .HasSseEvent(eventName, strictContentType, cancellationToken) |
SseResponseHasEventAssertion (chain) |
WithData, WithDataParsedAs<T>, WithId (both), WithRetryMillis, Until, AtLeast(int), AtMost(int), Exactly(int) |
string |
.HasSseEventIds() |
SseEventIdsAssertion (chain) |
StrictlyIncreasing(), Sequential(), Sequential(long startingAt), Until(string) |
Stream (incl. SseStream) |
.HasSseEventIds(cancellationToken) |
SseStreamEventIdsAssertion (chain) |
StrictlyIncreasing(), Sequential(), Sequential(long startingAt), Until(string) |
HttpResponseMessage |
.HasSseEventIds(cancellationToken) or .HasSseEventIds(strictContentType, cancellationToken) |
SseResponseEventIdsAssertion (chain) |
StrictlyIncreasing(), Sequential(), Sequential(long startingAt), Until(string) |
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 is read event by event inside the
assertion, and the read stops as soon as the outcome is decided: AtLeast(n)
passes at the n-th match, AtMost and Exactly fail once the count is exceeded,
HasFirstSseEvent reads one event, HasSseEventsInOrder stops once the sequence is
complete, and HasSseEventIds stops at the first violation. Until(eventName)
ends the observation window after the first event of that type, which bounds
AtMost, Exactly, and HasSseEventIds on an endless stream. Otherwise the read
runs to the end of the body or until the cancellation token ends it. The
retry-directive assertions read the whole body, because a bare retry: frame
carries no event; on a live stream, SseStream.FirstFrame gives the first
frame's retry: directly. The string receiver stops at the same point, so a body
gives the same verdict on every receiver. Bodies are decoded as UTF-8, as the
SSE specification requires.
HasSseEventIds() asserts that every event carries an id: (the property a
Last-Event-ID reconnection relies on); StrictlyIncreasing() and
Sequential() add the order of integer ids. Its failure lists the ids seen:
await Assert.That(sse).HasSseEventIds(ct).Sequential(startingAt: 1).Until("cycleCompleted");
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
Until event never arrived (the token ended the read before the event that
closes the window; the count itself was met):
the read to reach an event of type "done", which ends the observation window
but the read was canceled after 4 event(s), before one arrived
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: Waiting for an event without polling
For "the endpoint will emit a cache-ready event within ten seconds", open the
stream and assert on the event itself: the read returns the moment the event
arrives, and the token bounds the wait. There is no need to poll the endpoint with
TUnit's Eventually.
[Test]
public async Task CacheWarmup_EmitsReadyEvent(CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await using var sse = await SseStream.OpenAsync(_client, new Uri("/cache/status", UriKind.Relative), timeout.Token);
await Assert.That(sse).HasSseEvent("cache-ready", timeout.Token);
}
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 endpoints that never end
Production SSE endpoints typically stream indefinitely with heartbeats. Every streaming assertion stops reading once its outcome is decided, so most of them need nothing special:
[Test]
public async Task Ticks_ArriveInOrderWithSequentialIds(CancellationToken ct)
{
await using var sse = await SseStream.OpenAsync(_client, new Uri("/notifications/ticks", UriKind.Relative), ct);
await Assert.That(sse).HasSseEvent("tick", ct).AtLeast(2); // stops at the 2nd tick
await Assert.That(sse).HasSseEventIds(ct).Sequential().Until("checkpoint"); // stops at the checkpoint
}
AtMost, Exactly, and a passing HasSseEventIds need the end of a window to
decide: give it with Until(eventName) (the window ends after that event), or let
the token end the read. When the token ends the read before the Until event, the
assertion fails naming the event, even if the count was met; without Until, the
events that arrived before the token fired are evaluated.
Successive assertions on one SseStream continue where the previous one stopped.
To read events yourself, enumerate sse.ReadEventsAsync(ct) or use an
SseEventReader; stopping the enumeration leaves the stream at the next event.
A test-mode query parameter (/notifications/ticks?take=3, after which the
endpoint closes) still works and makes the read end with the body.
Out of scope
Read this before opening a feature request.
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. - Incremental reads that stop when decided. The
Stream/HttpResponseMessagereceivers read throughSseEventReader, which parses events as bytes arrive, so an assertion stops at the event that decides it instead of waiting for the end of the body or the token. Lines are split at the byte level (in UTF-8,\rand\nnever occur inside another character), so the bytes after the last event read stay unread: on anSseStream, the next assertion continues exactly there. When the token ends the read, the events that arrived are evaluated, and an unmet expectation reports the cancellation cut. Bodies are decoded as UTF-8, as the SSE specification requires; aContent-Typecharset is ignored. SseStreamis the body stream. The connect step returns aStream, positioned after the first frame, so every existing streaming assertion applies to it unchanged. A separate receiver type would have duplicated every chain, and asserting on the response after a read would have lost the bytes already consumed.- The token is required where it is the only bound.
SseStream.OpenAsyncandSseEventReader.ReadAsynctake aCancellationTokenwithout a default: nothing else ends a wait for a frame that never comes.
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
Nothing planned beyond the current surface; new entry points are demand-driven.
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.