JsonAssertions.TUnit 0.3.0

Prefix Reserved
dotnet add package JsonAssertions.TUnit --version 0.3.0
                    
NuGet\Install-Package JsonAssertions.TUnit -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="JsonAssertions.TUnit" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="JsonAssertions.TUnit" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="JsonAssertions.TUnit" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add JsonAssertions.TUnit --version 0.3.0
                    
#r "nuget: JsonAssertions.TUnit, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package JsonAssertions.TUnit@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=JsonAssertions.TUnit&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=JsonAssertions.TUnit&version=0.3.0
                    
Install as a Cake Tool

JsonAssertions.TUnit

NuGet Downloads License: MIT .NET

Scope: Test projects only. Not intended for production code.

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.

Full documentation, design notes, and roadmap: github.com/JohnVerheij/JsonAssertions.TUnit

Status: v0.3.0

Each path / value / shape entry point is available over a JSON string, a System.Text.Json.JsonElement, and an HttpResponseMessage (whose body is read as the JSON document). HTTP-response and AOT-context assertions target their natural receiver type.

Entry point Behaviour
HasJsonProperty(path) Asserts a property exists at the path.
DoesNotHaveJsonProperty(path) Asserts no property exists at the path.
HasJsonValue(path, expected) Asserts the value at path equals expected (a string, bool, or number).
HasJsonValueOneOf(path, T[]) Asserts the value at path is one of the given strings or numbers.
HasJsonValueMatching(path, predicate) Asserts the value at path satisfies Func<JsonElement, bool>.
HasJsonValueParsableAs<T>(path) Asserts the value at path is a JSON string parseable as T (where T : IParsable<T>).
HasJsonValueKind(path, kind) Asserts the value at path is of the given JsonValueKind.
HasJsonBoolean(path) Asserts the value at path is a JSON boolean (true or false).
HasNonEmptyJsonString(path) Asserts the value at path is a non-empty JSON string.
HasJsonArrayLength(path, length) Asserts the value at path is a JSON array of the given length.
HasNonEmptyJsonArray(path) / HasEmptyJsonArray(path) Asserts the value at path is a non-empty / empty JSON array.
HasJsonResponse<T>(status, JsonTypeInfo<T>, T expected, ct) on HttpResponseMessage Asserts status + AOT-clean deserialization + structural equality in one chain.
MatchesProblemDetails(status, ..., ct) on HttpResponseMessage Asserts an RFC 7807 application/problem+json response with matching fields.
MatchesValidationProblemDetails(status, 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 canonicalises a JSON string via the consumer's JsonSerializerContext; composes with SnapshotAssertions.TUnit's MatchesSnapshot(Func<>) at the consumer's call site.

The path is a dot-separated property navigation with optional [N] zero-based bracket indices and an optional leading $ JSONPath root reference: user.name, items[0].id, objects[0].planData[1].pickPlanId, $[0] for a root-array first element. See the path-syntax notes on GitHub for the full grammar.

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.

Install

dotnet add package JsonAssertions.TUnit

Requirements: TUnit 1.44.39 or later, .NET 10. System.Text.Json is in-box on .NET 10, so the package carries no runtime dependency beyond TUnit.

Quick start

using System.Text.Json;

[Test]
public async Task ResponseBodyHasExpectedShape(CancellationToken ct)
{
    string json = """{"user":{"name":"alice","age":30},"roles":["admin"]}""";

    await Assert.That(json).HasJsonProperty("user.name");
    await Assert.That(json).HasJsonValue("user.age", 30);
    await Assert.That(json).HasJsonArrayLength("roles", 1);
}

The fluent entry points auto-import via TUnit.Assertions.Extensions. The same entry points work on a JsonElement, and directly on an HttpResponseMessage:

// Reads the response body and asserts against it. The cancellation token flows to the read.
await Assert.That(response).HasJsonProperty("user.name", ct);
await Assert.That(response).HasJsonValue("user.age", 30, ct);

When an assertion fails, the message names the failure point:

to have a JSON property at path "user.address.city"
  resolved as far as: user.address
  no property "city" on "user.address"

A response body or string that is not valid JSON fails the assertion with an explained message rather than throwing a raw JsonException.

Two namespaces

The single package places types in two namespaces, the same shape as the rest of the assertion family:

Type Namespace Auto-imported?
JsonPath, JsonPathResolution, JsonValueComparison, JsonShape (framework-agnostic core) JsonAssertions No (needs using JsonAssertions;)
Source-generated assertion entry points TUnit.Assertions.Extensions Yes (TUnit auto-imports)

Roadmap

  • Semantic JSON equality and subset / fragment matching (IsEquivalentJsonTo, ContainsJson).
  • Wildcard path segments (items[*] and similar) when consumer evidence accumulates.

Family

Part of an assertion family for TUnit:

License

MIT. Copyright (c) 2026 John Verheij.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 110 5/17/2026
0.2.0 196 5/15/2026
0.1.0 112 5/14/2026
0.0.1 110 5/14/2026

View the rendered release notes: https://github.com/JohnVerheij/JsonAssertions.TUnit/releases/tag/v0.3.0

### Added

- Added **`RoundtripsCleanlyVia<T>(this T value, JsonTypeInfo<T> jsonTypeInfo)`** as an extension method on any value `T`, asserting the value serializes via the supplied `JsonTypeInfo<T>`, deserializes back, and re-serializes to a byte-identical JSON string. Catches the common "added a property and forgot to update the `JsonSerializerContext`" regression class at CI time. AOT-clean; failure messages render both serialized strings side-by-side for diagnosis.
- Added **`HasJsonTypeInfoFor<T>()` and the `AsJsonContext()` bridge** on `IAssertionSource<TContext>` where `TContext : JsonSerializerContext`. The leaf assertion asserts the underlying context registers a `JsonTypeInfo<T>` for `T`, catching the "added a domain type but forgot the `[JsonSerializable(typeof(NewType))]`" regression class. The educational-demand AOT-moat companion to `RoundtripsCleanlyVia`: where that assertion verifies a value round-trips cleanly through a typed context, `HasJsonTypeInfoFor` verifies the context knows about the type at all. The bridge extension `AsJsonContext()` produces an `IJsonContextAssertionSource` with the context viewed at the `JsonSerializerContext` base, keeping the call site to a single explicit type argument despite the receiver's concrete subtype (`await Assert.That(MyContext.Default).AsJsonContext().HasJsonTypeInfoFor<MyDto>()`). The pattern works around the C# partial-generic-inference limit via an internal upcast adapter; AOT-clean (one O(1) lookup against the context's type registry; no reflection).
- Added **`HasJsonResponse<T>(HttpStatusCode, JsonTypeInfo<T>, T expected, CancellationToken)`** on `HttpResponseMessage`, combining HTTP status + AOT-clean deserialization + structural-equality in one chain. Collapses the common `response.EnsureSuccessStatusCode() + body-read + Deserialize + AreEqual` 4-6-line pattern into a single fluent call. The supplied `JsonTypeInfo<T>` is the source-generated entry from the consumer's `JsonSerializerContext`; no runtime reflection. Failure messages include the response body (truncated at 256 chars) so the diagnostic surfaces the structured-error shape for non-200 responses and the actual JSON shape for deserialization failures. Status-only and predicate overloads deferred pending consumer demand.
- Added **`MatchesProblemDetails(int status, string? title, string? detail, string? type, string? instance, CancellationToken)`** on `HttpResponseMessage`, asserting the response is a valid RFC 7807 ProblemDetails (Content-Type `application/problem+json`, deserializable shape) and that each specified field matches. Unspecified fields skip (pass `null`). Deserializes via an internal `ProblemDetailsMirror` so the production package stays MIT-clean (no `Microsoft.AspNetCore.Mvc.Abstractions` Apache 2.0 dep) and AOT-clean via STJ source-gen. Mismatched fields report in a single failure message with expected-vs-got pairs. RFC 7807 §3.2 extension members are captured by the mirror's `[JsonExtensionData]` dictionary so they survive deserialization; a future `WithExtension(name, value)` chain method may expose the assertion surface.
- Added **`MatchesValidationProblemDetails(int status, IReadOnlyDictionary<string, string[]> errors, string? title, string? detail, string? type, string? instance, CancellationToken)`** on `HttpResponseMessage`. Same shape as `MatchesProblemDetails` plus the ASP.NET Core ValidationProblemDetails `errors` dictionary mapping field names to validation messages. Every key in the supplied `errors` dictionary must appear in the response with matching values.
- Added **`JsonRenderers.ReformatJson<T>(JsonTypeInfo<T>)`** in the `JsonAssertions` core namespace as a static factory returning `Func<string, string>` that canonicalises 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 (per CONVENTIONS.md v0.6 cross-package references rule). Two-step composition for HTTP responses: async read body in test, then sync canonicalise + snapshot. AOT-clean by construction.
- Promoted **`JsonFailureMessage` static class** in the `JsonAssertions` core namespace from `internal` to `public`. Exposes a curated subset of failure-message factory methods as the v0.3.0+ extension point for consumer-authored typed JSON assertions: `ParseFailure(JsonException)`, `PropertyNotFound(string path, JsonPathResolution)`, `PropertyShouldNotExist(string path, JsonPathResolution)`, `ValueMismatch(string path, JsonPathResolution, string expectedDescription)`, `ShapeMismatch(string path, JsonPathResolution, string expectedDescription)`. Consumers writing their own typed assertions can compose these factories to produce failure messages that match the package's diagnostic style. Mirrors the `MathAssertions.MathFailureMessage` pattern in the sibling package. HTTP-response, ProblemDetails, and roundtrip-specific factories remain `internal` — context-bound to their specific assertions, no extension value.

### Changed

- Updated **`CONVENTIONS.md` to v0.6**, adding the family-wide **Cross-package references rule** (no sibling family package may appear as a `PackageReference` in another sibling's production `.csproj`; composition happens at the consumer's call site via standard delegates) and the **Naming invariant** (no sibling-package-name prefix — `Snapshot*`, `Log*`, `Math*`, `Time*`, `Json*` — may appear in another sibling's public API surface, including typenames, method names, and extension method names). Both invariants are pack-time-enforced from v0.3.0 onward; `JsonAssertions.TUnit` ships the enforcement infrastructure (NuGet dependency-list scan + PublicAPI prefix scan) in this version. The 4 sibling repos adopt the same `CONVENTIONS.md` v0.6 in separate PRs after v0.3.0 merges.
- Set **`PackageValidationBaselineVersion`** to `0.2.0`. ApiCompat now validates the additive v0.3.0 surface against the v0.2.0 baseline; `CompatibilitySuppressions.xml` is regenerated to capture the accepted additive differences (the new HTTP-response, AOT-context, renderer, and bridge surfaces, plus `JsonFailureMessage`'s internal-to-public promotion).
- Changed **`RoundtripsCleanlyVia<T>`** to accept `null` payloads. A null value legitimately survives the round-trip through STJ (`null` → `"null"` → `null`); the previous early-rejection branch was removed. A non-null value that deserializes back to null (serializer corruption) still fails via the dedicated diagnostic.

### Fixed

- Fixed **`MatchesProblemDetails`** and **`MatchesValidationProblemDetails`** content-type comparison to be case-insensitive (RFC 9110 §8.3.2 media-type tokens). A response with `Application/Problem+Json` or any other case variant now correctly satisfies the RFC 7807 content-type check, where the previous ordinal comparison required exact `application/problem+json`.