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
<PackageReference Include="JsonAssertions.TUnit" Version="0.3.0" />
<PackageVersion Include="JsonAssertions.TUnit" Version="0.3.0" />
<PackageReference Include="JsonAssertions.TUnit" />
paket add JsonAssertions.TUnit --version 0.3.0
#r "nuget: JsonAssertions.TUnit, 0.3.0"
#:package JsonAssertions.TUnit@0.3.0
#addin nuget:?package=JsonAssertions.TUnit&version=0.3.0
#tool nuget:?package=JsonAssertions.TUnit&version=0.3.0
JsonAssertions.TUnit
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 | Versions 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. |
-
net10.0
- TUnit.Assertions (>= 1.44.39)
- TUnit.Core (>= 1.44.39)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
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`.