JsonAssertions.TUnit

CI CodeQL OpenSSF Scorecard codecov NuGet Downloads License: MIT .NET

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

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:
    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");
    
    When this fails the test message is "expected: True; got: False" - true at which level, and why, is on the caller to investigate manually.
  • A bespoke JsonAssertions helper 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.city resolves user, then address, then city.
  • items[0].name resolves the first element of items, then name on 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[*].name uses 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 by HasJsonProperty and HasJsonValueMatching; 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 [*].id passes vacuously on an empty array (there is nothing to violate the predicate), whereas [0].id fails 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 example HasNonEmptyJsonArray("items") alongside HasJsonProperty("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 has id 2", 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.
  • $ is the JSONPath root reference: $ alone resolves to the asserted element itself; $.user.name is equivalent to user.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 throws ArgumentException.

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 / double overloads, plus int / uint / long / ulong integer 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 HttpResponseMessage forms take a CancellationToken - the token flows to the response-body read, and there is nothing to cancel when the source is already a string or a JsonElement. Passing one anyway does not produce a helpful "no such overload": the compiler picks the HttpResponseMessage overload as the closest match and reports CS1929 ("... requires a receiver of type System.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) (over string / JsonElement) - structural equality, independent of property order and number form (1 == 1.0 == 1e0). The configure callback sets IgnorePath(path) (excludes a path, [*]-aware) and IgnoreArrayOrder() (multiset array comparison).
  • ContainsJson(expectedSubset) / ContainsJson(expectedSubset, configure) (v0.6.0+; over string, JsonElement, and HttpResponseMessage) - 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 and IgnorePath / IgnoreArrayOrder options 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-property HasJsonProperty / HasJsonValue checks against one response.

Typed extraction, not assertion (over JSON string and JsonElement; ...Async on HttpResponseMessage): (v0.6.0+)

  • GetJsonValue<T>(path) where T : IParsable<T> - reads and returns the value at path, parsed as T from 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, throws JsonExtractionException with the same path-context message the assertions render. These return values (they are not awaited assertions); the HttpResponseMessage forms are GetJsonValueAsync / GetJsonStringAsync / GetJsonElementAsync.
  • These extend the receiver, not the assertion chain: write response.GetJsonValueAsync<int>(...), not Assert.That(response).GetJsonValueAsync<int>(...). They live in the JsonAssertions namespace, so they need using JsonAssertions; - see Namespaces.

HTTP-response combined assertions (on HttpResponseMessage):

  • HasJsonResponse<T>(status, JsonTypeInfo<T>, expected, ct) - combined status + AOT-clean deserialization + structural equality
  • MatchesProblemDetails(status, title?, detail?, type?, instance?, ct) - RFC 7807
  • MatchesValidationProblemDetails(status, errors, ..., ct) - RFC 7807 + ASP.NET Core errors

AOT-context regression (on a JsonSerializerContext-typed source, via .AsJsonContext()):

  • AsJsonContext().HasJsonTypeInfoFor<T>() - asserts the context registers JsonTypeInfo<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 returning Func<string, string> that canonicalizes a JSON string for snapshot composition
  • JsonFailureMessage - public path-family factory methods (ParseFailure, PropertyNotFound, PropertyShouldNotExist, ValueMismatch, ShapeMismatch) for consumer-authored typed JSON assertions
  • JsonEquivalence.Compare(expected, actual, options) - structural comparison over JSON strings or JsonElements, returning the first JsonDifference or null when equivalent
  • JsonEquivalence.ContainsAll(expected, actual, options) (v0.6.0+) - subset comparison returning every JsonDifference (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. ContainsJson asserts 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 be Healthy or Degraded) 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 ContainsJson walks 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 with o => 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 over TryGetProperty(...).IsTrue().
  • Single package, not core + adapter. The bare JsonAssertions ID 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 (JsonAssertions core, JsonAssertions.TUnit adapter), so a split stays low-cost if the ID ever frees up.
  • Mirror types for ProblemDetails. MatchesProblemDetails deserializes into internal ProblemDetailsMirror types rather than Microsoft.AspNetCore.Mvc.ProblemDetails: the BCL type is Apache-2.0 (the family keeps shipped dependencies MIT-only), and the mirror's source-generated JsonSerializerContext makes 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 for HasJsonTypeInfoFor<T>. A single-generic HasJsonTypeInfoFor<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.
  • PackageValidationBaselineVersion pins to the previous shipped version so ApiCompat breakage is caught at pack time; CompatibilitySuppressions.xml records accepted differences.
  • 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. 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.Json does 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:

Pair with

  • LogAssertions.TUnit: fluent log assertions over Microsoft.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 (ThrowsGrpcException with StatusCode shorthands and detail refinements) plus the GrpcCallBuilder test-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 raw ActivityListener with no OpenTelemetry SDK dependency.
  • MetricsAssertions.TUnit: fluent assertions over System.Diagnostics.Metrics instruments (counters, histograms, gauges), built on MetricCollector.

Contributing

Issues and pull requests welcome. Before opening a PR:

  • Run dotnet build and dotnet test locally; the CI pipeline enforces the same quality bar (zero warnings as errors, 90% line / 90% branch coverage minimum).
  • Match the existing code style (.editorconfig is authoritative; dotnet format covers 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.