JsonAssertions.TUnit
TUnit-native JSON assertions for .NET. Fluent entry points over TUnit's Assert.That(...) pipeline for asserting against System.Text.Json documents, HTTP response bodies (including RFC 7807 ProblemDetails), and the registration state of source-generated JsonSerializerContext instances. 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.
Status
Property existence, value-at-path, value-predicate, value-one-of, value-parsable-as-T, shape (kind / array-length / non-empty / boolean / non-empty-string), HTTP-response JSON assertions (status + AOT-clean deserialization + structural equality, RFC 7807 ProblemDetails, ValidationProblemDetails), AOT-context regression assertions (RoundtripsCleanlyVia and HasJsonTypeInfoFor via the AsJsonContext bridge), and a canonicalizing-renderer (JsonRenderers.ReformatJson) for composition with SnapshotAssertions.TUnit at the consumer's call site. Each fluent entry point is available over a JSON string, a System.Text.Json.JsonElement, and an HttpResponseMessage (whose body is read as the JSON document):
| Entry point | Behavior |
|---|---|
HasJsonProperty(string path) |
Asserts a property exists at the path. |
DoesNotHaveJsonProperty(string path) |
Asserts no property exists at the path. |
HasJsonValue(string path, string\|bool\|number expected) |
Asserts the value at path equals expected. The integer overloads (int / uint / long / ulong) match the value whether the JSON encodes it as a number or a numeric string. |
HasJsonValueOneOf(string path, string[]\|double[]\|int[]\|uint[]\|long[]\|ulong[] candidates) |
Asserts the value at path is one of the given values. The integer overloads match a number or a numeric string. |
HasJsonValueMatching(string path, Func<JsonElement, bool> predicate) |
Asserts the value at path satisfies the predicate. |
HasJsonValueParsableAs<T>(string path) where T : IParsable<T> |
Asserts the value at path is a JSON string parseable as T (covers Guid, DateTimeOffset, Uri, ...). |
HasJsonValueKind(string path, JsonValueKind kind) |
Asserts the value at path is of the given kind. |
HasJsonBoolean(string path) |
Asserts the value at path is a JSON boolean (either true or false). |
HasNonEmptyJsonString(string path) |
Asserts the value at path is a non-empty JSON string. |
HasJsonArrayLength(string path, int length) |
Asserts the value at path is a JSON array of the given length. |
HasNonEmptyJsonArray(string path) / HasEmptyJsonArray(string path) |
Asserts the value at path is a non-empty / empty JSON array. |
IsEquivalentJsonTo(string expected) / IsEquivalentJsonTo(string expected, Action<JsonEquivalenceOptions> configure) over a JSON string or JsonElement |
Asserts the whole document is structurally equivalent to expected, independent of property order and number form. The configure callback sets IgnorePath and IgnoreArrayOrder. |
ContainsJson(string expectedSubset) / ContainsJson(string expectedSubset, Action<JsonEquivalenceOptions> configure) over a JSON string, JsonElement, or HttpResponseMessage (v0.6.0+) |
Asserts the document contains the expected subset (extra actual properties ignored); the failure lists every missing or mismatched path. Same options as IsEquivalentJsonTo. |
GetJsonValue<T>(string path) / GetJsonString(string path) / GetJsonElement(string path) over a JSON string or JsonElement (and GetJsonValueAsync / GetJsonStringAsync / GetJsonElementAsync on HttpResponseMessage) (v0.6.0+) |
Extraction, not assertion: reads a typed value out at path and returns it, for pulling an id to drive the next request or a count to cross-check an array length. GetJsonValue<T> parses any IParsable<T> from a JSON string or number; GetJsonElement returns a detached copy. Throws JsonExtractionException with path context on failure. |
HasJsonResponse<T>(HttpStatusCode, JsonTypeInfo<T>, T expected, ct) on HttpResponseMessage |
Asserts status + AOT-clean deserialization + structural equality in one chain. |
MatchesProblemDetails(int status, ..., ct) on HttpResponseMessage |
Asserts an RFC 7807 application/problem+json response with matching fields. |
MatchesValidationProblemDetails(int status, IReadOnlyDictionary<string, string[]> errors, ..., ct) on HttpResponseMessage |
Like MatchesProblemDetails plus the ASP.NET Core errors dictionary. |
RoundtripsCleanlyVia<T>(JsonTypeInfo<T>) on any T |
Asserts serialize -> deserialize -> re-serialize is byte-identical via the supplied source-generated JsonTypeInfo<T>. |
AsJsonContext().HasJsonTypeInfoFor<T>() on a JsonSerializerContext-typed source |
Asserts the supplied source-generated context registers a JsonTypeInfo<T> for T. |
JsonRenderers.ReformatJson<T>(JsonTypeInfo<T>) (static factory) |
Returns Func<string, string> that canonicalizes a JSON string via the consumer's JsonSerializerContext. Composes with SnapshotAssertions.TUnit's MatchesSnapshot(Func<>) at the consumer's call site without coupling the packages. |
The point over a hand-rolled TryGetProperty(...).IsTrue() helper is the failure message: every assertion renders a path-context block saying where resolution stopped, not merely that it did.
The AOT-context regression assertions (HasJsonTypeInfoFor, RoundtripsCleanlyVia) pair together as a CI gate to keep a source-generated JsonSerializerContext in sync with the consumer's domain types - see the dedicated section below.
Table of contents
- Why this package
- Install
- Package layout
- Namespaces (and a
GlobalUsings.csrecommendation) - Quick start
- Path syntax
- Entry points
- Failure diagnostics
- Cookbook: common patterns
- Design notes
- Stability intent (pre-1.0)
- Roadmap
- Out of scope
- Family compatibility
- Pair with
- Contributing
- License
Why this package
Asserting against a JSON response body or document in tests typically devolves into one of:
- A hand-rolled
TryGetProperty(...).IsTrue()chain with no failure context:
When this fails the test message is "expected: True; got: False" - true at which level, and why, is on the caller to investigate manually.using var doc = JsonDocument.Parse(body); await Assert.That(doc.RootElement.TryGetProperty("user", out var user) && user.TryGetProperty("name", out var name) && name.GetString() == "alice") .IsTrue().Because("user.name should equal alice"); - A bespoke
JsonAssertionshelper class re-implemented in every project, with subtle differences in failure messages, path-syntax, malformed-body handling, and HTTP-receiver wiring between codebases.
This package replaces both with a fluent DSL that auto-imports alongside TUnit's own assertions. Every assertion produces a path-context block on failure - the message names where on the path resolution stopped, not just that something didn't match - and the same entry points are available on a JSON string, a JsonElement, and an HttpResponseMessage (whose body is read as the JSON document, with the cancellation token flowing to the body read).
For the AOT-shipping audience, v0.3.0 adds two paired regression assertions (RoundtripsCleanlyVia + HasJsonTypeInfoFor) that catch the "added a new domain type but forgot to add [JsonSerializable(typeof(NewType))] to the context" class at CI time, before any runtime serialization touches the unregistered type.
Install
dotnet add package JsonAssertions.TUnit
Requirements: TUnit 1.62.0 or later, .NET 10. System.Text.Json is in-box on .NET 10, so the package carries no runtime dependency beyond TUnit. The package is AOT-compatible, trimmable, and uses no runtime reflection in the assertion path.
Package layout
This repo ships one NuGet package with types in two namespaces - the same consumer feel as the rest of the assertion family (a framework-agnostic core plus a TUnit adapter), but in a single assembly to keep zero-overhead packaging at the v0.x stage:
| Package | Purpose | Depends on |
|---|---|---|
JsonAssertions.TUnit |
JsonAssertions framework-agnostic core (JsonPath, JsonValueComparison, JsonShape, JsonRenderers, JsonFailureMessage) plus JsonAssertions.TUnit TUnit adapter ([GenerateAssertion] entry points + hand-written extensions over Assert.That(...)) |
System.Text.Json (in-box on net10.0) + TUnit.Assertions + TUnit.Core |
A future package split (a bare-identifier JsonAssertions core package plus this adapter) would fall along the namespace seam if the standalone identifier becomes available; shipping the seam now keeps the option open without committing to it. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped today; they would reuse the JsonAssertions core. Open a feature request if you need one.
Namespaces (and a GlobalUsings.cs recommendation)
The single package places its own types in two namespaces with deliberately-different scopes, and the source generator emits the fluent entry points into a third that belongs to TUnit:
| Type / member | Namespace | Auto-imported? |
|---|---|---|
JsonPath, JsonPathResolution, JsonValueComparison, JsonShape, JsonRenderers, JsonFailureMessage (framework-agnostic core) |
JsonAssertions |
No (needs using JsonAssertions;) |
Typed extraction (GetJsonValue<T>(), GetJsonString(), GetJsonElement() and the ...Async forms) |
JsonAssertions |
No (needs using JsonAssertions;) |
Source-generated assertion entry points (HasJsonProperty(), HasJsonValue(), HasJsonResponse(), MatchesProblemDetails(), ...) |
TUnit.Assertions.Extensions |
Yes (TUnit auto-imports) |
IJsonContextAssertionSource + AsJsonContext() bridge (JSON-context family) |
JsonAssertions.TUnit |
No (needs using JsonAssertions.TUnit; when chaining .AsJsonContext()) |
The extraction methods are the one part of this package that does not hang off Assert.That(...). They are extensions on the receiver (the HttpResponseMessage, string, or JsonElement itself), because they return a value rather than asserting one. The rest of the package trains you to reach for the chain, so the wrong spelling is the natural first guess, and it fails with a bare CS1061 that never mentions the namespace you are missing:
using JsonAssertions; // the extraction methods live here, and do not auto-import
// RIGHT: the receiver, not the assertion
var orderId = await response.GetJsonValueAsync<int>("orderId", ct);
// WRONG: ValueAssertion<HttpResponseMessage> has no such method, and the
// resulting CS1061 does not mention the namespace you are missing
// await Assert.That(response).GetJsonValueAsync<int>("orderId", ct);
Only the fluent assertion entry points auto-import (via TUnit.Assertions.Extensions), so a test project that already uses TUnit needs no using for them. The other three rows need two using directives between them: using JsonAssertions; covers both the typed extraction and the core types, and using JsonAssertions.TUnit; covers .AsJsonContext(). A single GlobalUsings.cs puts both in front of every test file without ceremony:
// tests/MyApp.Tests/GlobalUsings.cs
global using JsonAssertions; // for GetJsonValue<T>() and the core types
global using JsonAssertions.TUnit; // for .AsJsonContext()
JsonPath.Resolve(JsonElement, string) is the framework-agnostic primitive: it returns a JsonPathResolution carrying the resolved element on success and the failure-point context (how far it got, which segment failed, what kind of value blocked it) on failure. JsonValueComparison.Matches compares a resolved element against an expected value, and JsonShape provides the array-length / value-kind predicates. The TUnit entry points are thin [GenerateAssertion] wrappers over them.
Quick start
using System.Text.Json;
[Test]
public async Task ResponseBodyHasExpectedShape(CancellationToken ct)
{
string json = """{"user":{"name":"alice","age":30,"active":true,"id":"550e8400-e29b-41d4-a716-446655440000"},"items":[{"name":"x"}],"status":"Healthy"}""";
await Assert.That(json).HasJsonProperty("user.name");
await Assert.That(json).DoesNotHaveJsonProperty("user.email");
await Assert.That(json).HasJsonValue("user.age", 30);
await Assert.That(json).HasJsonValue("user.active", true);
await Assert.That(json).HasJsonProperty("items[0].name");
await Assert.That(json).HasJsonValueOneOf("status", ["Healthy", "Degraded", "Unhealthy"]);
await Assert.That(json).HasJsonValueParsableAs<Guid>("user.id");
}
The fluent entry points auto-import via TUnit.Assertions.Extensions; no extra using directive is needed beyond standard TUnit usings.
The same entry points are available on a JsonElement, and directly on an HttpResponseMessage whose body is read as the JSON document. The HttpResponseMessage overloads are asynchronous and take an optional CancellationToken that flows to the body read:
using var document = JsonDocument.Parse(json);
await Assert.That(document.RootElement).HasJsonValue("user.name", "alice");
// Directly on an HTTP response - the body is read and asserted against:
await Assert.That(response).HasJsonProperty("user.name", ct);
await Assert.That(response).HasJsonArrayLength("items", 3, ct);
A response body or string that is not valid JSON fails the assertion with an explained message rather than throwing a raw JsonException; a body that cannot be parsed does not vacuously satisfy DoesNotHaveJsonProperty.
Integer overloads: number or numeric string
The integer overloads (int / uint / long / ulong) match the value whether the JSON encodes it as a number or a numeric string, because the encoding depends on the serializer. long / ulong exist because protobuf serializes 64-bit ints as JSON strings (to avoid 53-bit precision loss), while System.Text.Json writes them as numbers, and both now match. A passing value is an exact integer in range: a fractional number, an out-of-range number, or a non-numeric string fails. The double overload matches a JSON number only. Use the L / UL suffix to assert a 64-bit value:
// number-encoded (System.Text.Json): {"guid":{"high":123456789012345,"low":42}}
await Assert.That(json).HasJsonValue("guid.high", 123456789012345L);
// string-encoded (protobuf JsonFormatter): {"guid":{"high":"123456789012345","low":"18446744073709551615"}}
await Assert.That(json).HasJsonValue("guid.high", 123456789012345L);
await Assert.That(json).HasJsonValue("guid.low", 18446744073709551615UL);
await Assert.That(json).HasJsonValueOneOf("message.sequence", [100L, 200L, 300L]);
When an assertion fails, the message names the failure point rather than just reporting a false:
to have a JSON property at path "user.address.city"
resolved as far as: user.address
no property "city" on "user.address"
Path syntax
A path is a sequence of dot-separated property names and zero-based bracket indices, navigated from the asserted element (or, for the string overload, from the parsed document's root):
user.address.cityresolvesuser, thenaddress, thencity.items[0].nameresolves the first element ofitems, thennameon it. Indices are zero-based, non-negative integers. Property and index segments compose freely (objects[0].entries[1].id).
Why dot + [n], not JSONPath or JMESPath? The path is a deliberately small, navigable subset, not a query language. A full JSONPath/JMESPath engine would add a runtime dependency (several are reflection-based) or a hand-rolled query parser, both at odds with this package's BCL-and-TUnit-only, AOT-first, no-reflection rules. The subset covers the common assertion case: navigate to a known location and assert its value or shape. Beyond the [*] wildcard it does not support filter expressions or recursive descent; for those, resolve the element yourself and assert on the result.
items[*].nameuses the[*]wildcard (since v0.4.0): it matches every element of the array, so the assertion holds only if it holds for all of them. Nested and multiple wildcards compose (cycles[*].cycleId,[*].tags[*]). Supported byHasJsonPropertyandHasJsonValueMatching; an empty array passes vacuously (a "for all" over an empty set), and a failure names the first failing element by its concrete index.- Empty-array footgun.
[*]is a "for all" quantifier, so[*].idpasses vacuously on an empty array (there is nothing to violate the predicate), whereas[0].idfails on an empty array (there is no first element). A naive[0]to[*]migration therefore silently drops the implicit non-emptiness check that[0]carried. When an empty array should fail the test, pair the wildcard with an explicit non-emptiness assertion, for exampleHasNonEmptyJsonArray("items")alongsideHasJsonProperty("items[*].id"). - Wildcards are for existence and uniform checks, not element-specific ones.
[*]fits property-existence checks and value checks that genuinely hold for every element. A check that depends on a specific element's position (for example "the element at index 2 hasid2",HasJsonValue("items[2].id", 2)) must stay index-scoped; rewriting it as[*]would assert the same value for every element and change the meaning of the test.
- Empty-array footgun.
$is the JSONPath root reference:$alone resolves to the asserted element itself;$.user.nameis equivalent touser.name;$[0](and bare[0]) is equivalent against a root array.- A path that traverses a non-object value where a property is expected (or a non-array where an index is expected) resolves to "not found" rather than throwing; the failure message names which segment blocked the resolution.
- An empty path, a whitespace path, an empty / non-numeric / negative bracket index, an unclosed
[, a property name directly after]without a.separator, or a doubled or leading / trailing dot throwsArgumentException.
The single-location assertions (HasJsonValue, HasJsonValueOneOf, HasJsonValueParsableAs<T>, HasJsonArrayLength, ...) target one path and reject [*] as a malformed index; the wildcard is for the all-element assertions above. For pinning a whole response shape as a snapshot, JsonCanonicalizer.Canonicalize(json, opts) (since v0.4.0) produces a deterministic structural form (object keys sorted, stable two-space indent, LF, every field preserved) with [*]-aware ScrubPath scrubbing of volatile values; compose it with a snapshot library's normalizer hook. Unlike the typed JsonRenderers.ReformatJson<T>, it needs no JsonSerializerContext and keeps unknown fields, so an added or removed field surfaces as a diff.
Entry points
The full entry-point catalog is in the Status table at the top of this file. The summary below organizes them by domain.
Path / value / shape (over JSON string, JsonElement, and HttpResponseMessage):
HasJsonProperty(path)/DoesNotHaveJsonProperty(path)HasJsonValue(path, expected)(string/bool/doubleoverloads, plusint/uint/long/ulonginteger overloads that match a number or a numeric string)HasJsonValueOneOf(path, candidates[])(string[]/double[]/int[]/uint[]/long[]/ulong[]overloads)HasJsonValueMatching(path, predicate)HasJsonValueParsableAs<T>(path)(where T : IParsable<T>)HasJsonValueKind(path, kind)/HasJsonBoolean(path)/HasNonEmptyJsonString(path)HasJsonArrayLength(path, length)/HasNonEmptyJsonArray(path)/HasEmptyJsonArray(path)
Only the
HttpResponseMessageforms take aCancellationToken- the token flows to the response-body read, and there is nothing to cancel when the source is already astringor aJsonElement. Passing one anyway does not produce a helpful "no such overload": the compiler picks theHttpResponseMessageoverload as the closest match and reportsCS1929("... requires a receiver of typeSystem.Net.Http.HttpResponseMessage"), which points at the receiver when the fix is to drop the token.await Assert.That(element).HasJsonArrayLength("items", 2, ct); // CS1929, misleading await Assert.That(element).HasJsonArrayLength("items", 2); // correct: nothing to cancel
Whole-document equivalence and subset (over JSON string, JsonElement, and HttpResponseMessage):
IsEquivalentJsonTo(expected)/IsEquivalentJsonTo(expected, configure)(overstring/JsonElement) - structural equality, independent of property order and number form (1==1.0==1e0). The configure callback setsIgnorePath(path)(excludes a path,[*]-aware) andIgnoreArrayOrder()(multiset array comparison).ContainsJson(expectedSubset)/ContainsJson(expectedSubset, configure)(v0.6.0+; overstring,JsonElement, andHttpResponseMessage) - subset match: every property in the expected document must be present and equal in the actual document, which may carry additional properties. Same order/number-form independence andIgnorePath/IgnoreArrayOrderoptions as equivalence; an expected array asserts a positional prefix. The failure lists every missing or mismatched path, not just the first. Use it to replace a block of per-propertyHasJsonProperty/HasJsonValuechecks against one response.
Typed extraction, not assertion (over JSON string and JsonElement; ...Async on HttpResponseMessage): (v0.6.0+)
GetJsonValue<T>(path)whereT : IParsable<T>- reads and returns the value atpath, parsed asTfrom a JSON string or number literal (invariant culture), for driving a later request or a cross-check.GetJsonString(path)returns a JSON string value;GetJsonElement(path)returns a detached (Cloned) subtree that survives the source document's disposal. On a failure to resolve the path, convert the value, or parse the source, throwsJsonExtractionExceptionwith the same path-context message the assertions render. These return values (they are not awaited assertions); theHttpResponseMessageforms areGetJsonValueAsync/GetJsonStringAsync/GetJsonElementAsync.- These extend the receiver, not the assertion chain: write
response.GetJsonValueAsync<int>(...), notAssert.That(response).GetJsonValueAsync<int>(...). They live in theJsonAssertionsnamespace, so they needusing JsonAssertions;- see Namespaces.
HTTP-response combined assertions (on HttpResponseMessage):
HasJsonResponse<T>(status, JsonTypeInfo<T>, expected, ct)- combined status + AOT-clean deserialization + structural equalityMatchesProblemDetails(status, title?, detail?, type?, instance?, ct)- RFC 7807MatchesValidationProblemDetails(status, errors, ..., ct)- RFC 7807 + ASP.NET Coreerrors
AOT-context regression (on a JsonSerializerContext-typed source, via .AsJsonContext()):
AsJsonContext().HasJsonTypeInfoFor<T>()- asserts the context registersJsonTypeInfo<T>RoundtripsCleanlyVia<T>(JsonTypeInfo<T>)- serialize -> deserialize -> re-serialize is byte-identical
Composition + extension point (in JsonAssertions core namespace):
JsonRenderers.ReformatJson<T>(JsonTypeInfo<T>)- static factory returningFunc<string, string>that canonicalizes a JSON string for snapshot compositionJsonFailureMessage- public path-family factory methods (ParseFailure,PropertyNotFound,PropertyShouldNotExist,ValueMismatch,ShapeMismatch) for consumer-authored typed JSON assertionsJsonEquivalence.Compare(expected, actual, options)- structural comparison over JSON strings orJsonElements, returning the firstJsonDifferenceornullwhen equivalentJsonEquivalence.ContainsAll(expected, actual, options)(v0.6.0+) - subset comparison returning everyJsonDifference(empty when the actual document contains the expected subset);JsonFailureMessage.ContainsMismatch(differences)renders the multi-difference failure text
Failure diagnostics
Every assertion produces a path-context block on failure: the message names where on the path navigation stopped, not just that something didn't match.
Path-resolution failure:
to have a JSON property at path "user.address.city"
resolved as far as: user.address
no property "city" on "user.address"
Array-index failure on a non-array:
to have a JSON property at path "user[0]"
resolved as far as: user
cannot index [0]: "user" is an Object, not an array
Malformed-body failure (the JSON string or HTTP response body wasn't valid JSON):
the asserted value to be parseable JSON
but parsing failed: '{' is invalid after a property name. Expected a ':'. Path: $.user | LineNumber: 0 | BytePositionInLine: 14.
HTTP combined-assertion failure (status correct, body deserialized but the resulting value differed from expected):
the response to deserialize to a TestDto structurally equal to expected
expected: TestDto { Id = 42, Name = alice }
got: TestDto { Id = 99, Name = bob }
body: {"Id":99,"Name":"bob"}
RFC 7807 ProblemDetails field-mismatch failure (collected into a single message for all non-matching fields):
the response to be RFC 7807 ProblemDetails with the asserted fields
title: expected "Validation failed" got "Bad request"
detail: expected "Field X is required" got ""
AOT-context registration failure (the source-gen context does not register the asserted type):
to register JsonTypeInfo<DateTime>
the JsonSerializerContext to have a JsonTypeInfo registered for DateTime
but MyJsonContext.GetTypeInfo(typeof(DateTime)) returned null
hint: add [JsonSerializable(typeof(DateTime))] to MyJsonContext
AOT round-trip drift failure (the value re-serialized to a different JSON shape):
the value to round-trip cleanly through the supplied JsonTypeInfo
but the serialized form drifted between trips:
first: {"id":42,"name":"alice"}
second: {"id":42,"name":"alice","drift":true}
Failure-message text is not part of the stable public surface; pin behavior against the JsonPath / JsonValueComparison / JsonShape primitives, not against full message-text equality.
Cookbook: common patterns
Pattern: pull a value out to drive the next step (GetJsonValue, v0.6.0+)
Acceptance and end-to-end flows often read a value from one response to build the next request, or read a count to cross-check an array length. Instead of a hand-rolled JsonDocument.Parse(...).RootElement.GetProperty("orderId").GetInt32() (which throws a bare KeyNotFoundException / InvalidOperationException with no path context), extract it with a typed getter:
// parse the body once, then read what the test needs
var order = await response.GetJsonElementAsync("order", ct);
var orderId = order.GetJsonValue<int>("orderId"); // drives the next request
await _client.PostAsJsonAsync($"/orders/{orderId}/submit", payload, ct);
// cross-field consistency
var itemCount = order.GetJsonValue<int>("itemCount");
var items = order.GetJsonElement("items");
await Assert.That(itemCount).IsEqualTo(items.GetArrayLength());
Note the receiver: these hang off the response / JsonElement itself, not off Assert.That(...), and they need using JsonAssertions;. They return a value instead of asserting one, so they are not part of the assertion chain - see Namespaces for the spelling that does not compile.
GetJsonValue<T> parses any IParsable<T> (int, long, bool, Guid, DateTimeOffset, ...) from a JSON string or number literal; a missing path or unparseable value throws JsonExtractionException naming where the path stopped. GetJsonElement returns a detached copy, so it stays valid after the parsed document is gone.
Pattern: assert a response contains an expected shape (ContainsJson, v0.6.0+)
When a test asserts several fields of one response, a block of per-property checks re-reads the body per line and restates each path in a .Because. ContainsJson collapses the block into one declarative subset assertion: the expected fields must be present and equal, and the response may carry any additional fields (durations, timestamps, versions) the test does not want to pin. The failure lists every missing or mismatched field in one report, so a single run tells you everything that is wrong.
// Before: one assert per field, each re-reads the body, each restates the path
await Assert.That(response).HasJsonProperty("status", ct).Because("aggregate health has status");
await Assert.That(response).HasJsonProperty("totalDurationMs", ct).Because("has totalDurationMs");
await Assert.That(response).HasNonEmptyJsonArray("entries", ct).Because("at least one entry");
await Assert.That(response).HasJsonProperty("entries[0].name", ct);
await Assert.That(response).HasJsonProperty("entries[0].status", ct);
// After: one subset assertion; extra response fields are ignored; failure names every wrong path
await Assert.That(response).ContainsJson("""
{ "status": "Healthy",
"entries": [ { "name": "db", "status": "Healthy" } ] }
""", ct);
Use IsEquivalentJsonTo instead when the test must reject unexpected fields (full equality). ContainsJson also works over a JSON string and a JsonElement; when a test asserts status, shape, and extracts a value, parse the body once to a JsonElement and assert against that. An expected array asserts a positional prefix (the actual array may be longer); pass o => o.IgnoreArrayOrder() to match elements as a multiset subset, or o => o.IgnorePath("...") to exclude a volatile field.
Do not fold a volatile value into the expected subset.
ContainsJsonasserts equality for every field you put in it - "subset" constrains which fields are checked, not how strictly they are compared. A field whose value legitimately varies between runs (an elapsed-milliseconds or duration reading, a server-generated identifier, a health status that can beHealthyorDegraded) is pinned to whatever the response happened to say the day the test was written, which asserts more than the contract does and fails on the first run that legitimately differs.This is the failure mode a migration onto
ContainsJsonwalks into: collapsing a block of per-property checks feels mechanical, so a volatile field is easy to sweep in with the stable ones, and a presence check (HasJsonProperty("totalDurationMs")) silently becomes an equality check. The test is now flaky - and the usual cure for a flaky assertion is to delete it, which is how the field ends up checked by nothing at all. Keep volatile fields on their own presence or predicate assertion, or exclude them witho => o.IgnorePath("..."):// status and the entry shape are the contract; totalDurationMs is not await Assert.That(response).ContainsJson(""" { "status": "Healthy", "entries": [ { "name": "db" } ] } """, ct); await Assert.That(response).HasJsonProperty("totalDurationMs", ct); // present, not pinned
Pattern: assert an HTTP-response JSON body in one fluent call
HasJsonResponse<T> collapses the "check status + read body + deserialize + structural compare" pattern into a single fluent call. The deserialization is AOT-clean (uses the source-generated JsonTypeInfo<T>); the failure message includes the body (truncated at 256 chars) so non-200 responses or shape mismatches surface their actual content.
[Test]
public async Task GetOrder_ReturnsExpectedOrder(CancellationToken ct)
{
using var response = await _httpClient.GetAsync("/orders/42", ct);
await Assert.That(response).HasJsonResponse(
HttpStatusCode.OK,
MyJsonContext.Default.OrderDto,
expected: new OrderDto(Id: 42, Customer: "alice", Total: 19.99m),
cancellationToken: ct);
}
Pattern: assert an RFC 7807 ProblemDetails response
MatchesProblemDetails asserts the response is a valid RFC 7807 ProblemDetails (Content-Type application/problem+json - case-insensitive per RFC 9110, deserializable shape) and that each specified field matches. Unspecified fields skip (pass null). The Apache 2.0 Microsoft.AspNetCore.Mvc.Abstractions dependency is not required at runtime - the assertion uses an internal mirror type so the production package stays MIT-clean.
[Test]
public async Task PostOrder_OnValidationFailure_ReturnsProblemDetails(CancellationToken ct)
{
using var response = await _httpClient.PostAsJsonAsync("/orders", new { Quantity = -1 }, ct);
await Assert.That(response).MatchesProblemDetails(
status: 400,
title: "Validation failed",
type: "https://example.com/probs/validation",
cancellationToken: ct);
}
For ASP.NET Core's ValidationProblemDetails (which carries an errors dictionary keyed by field name), use MatchesValidationProblemDetails instead.
MatchesProblemDetails asserts the media type as well as the fields. A test that existed only to check for application/problem+json is therefore already covered by it, and migrating both that test and a separate status/field test onto this one assertion leaves the two with identical bodies - which a strict analyzer set flags (SonarAnalyzer S4144 fails the build on duplicate method implementations). Drop the now-redundant content-type test. If you would rather keep a focused one for its narrower failure message, assert the media type directly with HasContentType instead of restating the ProblemDetails check.
Pattern: AOT round-trip CI gate (RoundtripsCleanlyVia + HasJsonTypeInfoFor)
HasJsonTypeInfoFor<T> is the only assertion here that guards a failure class the rest of your suite can miss entirely. A domain type missing from the JsonSerializerContext is not a compile error, and nothing fails until something actually serializes that exact type. Whether it surfaces before deployment depends on the resolver chain: with a reflection fallback in the chain (the common default), a JIT test run serializes the unregistered type happily, while the published Native-AOT binary - which has no reflection to fall back to - throws at runtime. That is the worst shape a bug can have: green in CI, broken in production, on a code path a test never thought to cover. One assertion per registered type moves the check to CI, where it belongs.
The pair of regression assertions catches the "added a domain type but forgot to update the JsonSerializerContext" class at CI time, before any runtime serialization touches the unregistered type. RoundtripsCleanlyVia verifies that a populated instance survives serialize -> deserialize -> re-serialize through a specific JsonTypeInfo<T> without drift; HasJsonTypeInfoFor verifies the context knows about the type at all.
[Test]
public async Task SerializerContextStaysInSyncWithDomainTypes()
{
// The context registers every domain type the assertion expects.
await Assert.That(MyJsonContext.Default).AsJsonContext().HasJsonTypeInfoFor<UserDto>();
await Assert.That(MyJsonContext.Default).AsJsonContext().HasJsonTypeInfoFor<OrderDto>();
await Assert.That(MyJsonContext.Default).AsJsonContext().HasJsonTypeInfoFor<HealthCheckResponse>();
// Each registered type round-trips cleanly through that registration.
var user = new UserDto(Id: 1, Name: "alice", Active: true);
var order = new OrderDto(Id: 42, Customer: "alice", Total: 19.99m);
await Assert.That(user).RoundtripsCleanlyVia(MyJsonContext.Default.UserDto);
await Assert.That(order).RoundtripsCleanlyVia(MyJsonContext.Default.OrderDto);
}
.AsJsonContext() is a one-method bridge that produces an IJsonContextAssertionSource whose Context is statically typed at JsonSerializerContext. The bridge exists because Assert.That(MyJsonContext.Default) returns an assertion source typed at the concrete subtype (IAssertionSource<MyJsonContext>), and C# does not allow partial generic-type-argument inference. The bridge moves the receiver type out of the leaf assertion's signature, restoring the single-explicit-generic shape (HasJsonTypeInfoFor<MyDto>()) consumers expect. The pattern is AOT-clean (one O(1) lookup against the context's type registry; the upcast goes through TUnit's existing Map pipeline, no reflection).
Pattern: compose with SnapshotAssertions.TUnit for canonicalized body snapshots
JsonRenderers.ReformatJson<T> returns a Func<string, string> that canonicalizes a JSON string via the consumer's JsonSerializerContext (deserialize then re-serialize through the supplied JsonTypeInfo<T>). Composes with SnapshotAssertions.TUnit's MatchesSnapshot(Func<>) overload at the consumer's call site, without coupling the packages (the family's Cross-package references rule keeps production-side PackageReferences separate; composition happens at the test's call site via standard delegates):
[Test]
public async Task PostOrder_ResponseBodyMatchesSnapshot(CancellationToken ct)
{
using var response = await _httpClient.PostAsJsonAsync("/orders", new { ... }, ct);
var body = await response.Content.ReadAsStringAsync(ct);
// Canonicalizes body via MyJsonContext (deterministic ordering + formatting), then snapshots.
await Assert.That(body).MatchesSnapshot(
JsonRenderers.ReformatJson(MyJsonContext.Default.OrderDto));
}
The two-step composition (async body-read in the test, sync canonicalize + snapshot) keeps the rule "no sync-over-async" intact while letting the snapshot pipeline operate on a deterministic JSON shape.
Pattern: extend the same DSL with consumer-authored typed assertions
For domain-specific assertions that this package doesn't ship, the JsonAssertions.JsonFailureMessage factory methods (ParseFailure, PropertyNotFound, PropertyShouldNotExist, ValueMismatch, ShapeMismatch) produce failure messages that match the package's diagnostic style. Compose them with TUnit's [GenerateAssertion] to ship a typed assertion alongside your own DTO conventions:
public static class OrderAssertions
{
[GenerateAssertion]
public static AssertionResult HasOrderStatusOf(this string body, string expectedStatus)
{
var doc = JsonDocument.Parse(body);
var resolution = JsonPath.Resolve(doc.RootElement, "order.status");
if (!resolution.Found)
{
return AssertionResult.Failed(JsonFailureMessage.PropertyNotFound("order.status", resolution));
}
return string.Equals(resolution.Element.GetString(), expectedStatus, StringComparison.Ordinal)
? AssertionResult.Passed
: AssertionResult.Failed(JsonFailureMessage.ValueMismatch("order.status", resolution, $"\"{expectedStatus}\""));
}
}
// Call site (auto-import via [GenerateAssertion]):
await Assert.That(body).HasOrderStatusOf("Paid");
Design notes
- Path-context failure messages. A bare
HasJsonProperty("user.address.city")failure that says "True expected; got: False" forces you to instrument the test to find which segment failed. Every assertion here walks the path and reports the longest prefix that resolved, the segment that blocked, and the value kind at the blocking point. This is the load-bearing reason the package exists; without it, it would be a thin wrapper overTryGetProperty(...).IsTrue(). - Single package, not core + adapter. The bare
JsonAssertionsID is taken on nuget.org, so a separate core could not use the matching name; the JSON primitives are also thin enough that a split would be near-empty. Types still live in two namespaces (JsonAssertionscore,JsonAssertions.TUnitadapter), so a split stays low-cost if the ID ever frees up. - Mirror types for ProblemDetails.
MatchesProblemDetailsdeserializes into internalProblemDetailsMirrortypes rather thanMicrosoft.AspNetCore.Mvc.ProblemDetails: the BCL type is Apache-2.0 (the family keeps shipped dependencies MIT-only), and the mirror's source-generatedJsonSerializerContextmakes deserialization AOT-clean. The test project asserts the mirror's shape against the real ASP.NET Core type, catching wire-format drift at CI time. - The
.AsJsonContext()bridge forHasJsonTypeInfoFor<T>. A single-genericHasJsonTypeInfoFor<MyDto>()would otherwise need a second receiver-type generic at every call site (C# cannot partially infer type arguments). The bridge upcasts the source to a statically-JsonSerializerContext-typed wrapper, restoring the single-generic shape. Zero-cost, AOT-clean. Background: thomhurst/TUnit#5922.
Stability intent (pre-1.0)
This is a 0.x release and the public API may evolve.
- Additive changes (new entry points, new input overloads) 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 breakage is caught at pack time;CompatibilitySuppressions.xmlrecords accepted differences.- Failure-message text is not part of the stable public surface; pin behavior against the
JsonPath/JsonValueComparison/JsonShapeprimitives, not against full message-text equality. The 1.0 milestone signals API stability.
Roadmap
The next increments, each as a reviewed pull request:
- Semantic JSON equality already ships as
IsEquivalentJsonTo(0.5.0); subset matching (ContainsJson) and typed extraction (GetJsonValue<T>/GetJsonString/GetJsonElement) in 0.6.0. Further increments are demand-driven.
Out of scope
- Production-code use (per the scope blockquote above).
- JSON schema validation out of scope for this package: it needs a JSON Schema engine (a runtime dependency
System.Text.Jsondoes not provide) and is a different mental model from path / value / shape assertions. A future opt-in adapter package could add it if demand appears, keeping the schema-engine dependency out of this zero-dependency core. - A JSON serializer or parser. The package builds on
System.Text.Json; it does not replace it.
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).SseAssertions.TUnit: Server-Sent Events (SSE) wire-format assertions: event-count, field shape (event:,data:,id:,retry:), and stream content validation.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, SseAssertions.TUnit, and this repo.
License
MIT. Copyright (c) 2026 John Verheij.