Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
History note: All version sections were reformatted on 2026-07-05 for one-time CONVENTIONS §CHANGELOG conformance: forbidden sub-headers folded into the six Keep a Changelog headers, non-user-facing content removed (internal refactors, test counts, coverage numbers, CI and build hygiene, governance churn, roadmap notes), bullets kept in past-tense active voice with code-formatted API leads. The nuget.org Release Notes tab and the GitHub Release for each shipped version are unchanged. A CI
family-lintgate keeps future sections conforming; each is frozen per Rule 7 once shipped.
Unreleased
0.6.1 - 2026-07-12: document the 0.6.0 traps
Patch release. Documentation only; no API or behavioral change. Everything here comes from migrating a real suite onto 0.6.0, where the two new features landed cleanly but three shapes in the surface misled a reader who was pattern-matching from the rest of the package.
Changed
ContainsJsondocumentation now carries an explicit warning against folding a volatile value into the expected subset.ContainsJsonasserts equality for every field placed in it: "subset" constrains which fields are checked, not how strictly they are compared. Pinning a duration reading, a server-generated identifier, or a legitimately-varying status therefore asserts more than the contract does, and the test fails on the first run that differs. This is the failure mode a migration onto 0.6.0 walks into, because collapsing a block of per-property checks feels mechanical enough to sweep a volatile field in with the stable ones, turning a presence check into an equality check.GetJsonValue<T>/GetJsonString/GetJsonElementare now documented as extending the receiver rather than the assertion chain, and as living in theJsonAssertionsnamespace, so they needusing JsonAssertions;. Every other feature auto-imports throughTUnit.Assertions.Extensions, which trains a reader to reach forAssert.That(response).GetJsonValueAsync<T>(...)first: that does not compile, and the resultingCS1061never names the missing namespace. The namespace table, the entry-point list, and the cookbook pattern now all show the receiver form.HasJsonArrayLengthand the other shape and value assertions now document that only theHttpResponseMessageforms take aCancellationToken(it flows to the body read; astringorJsonElementsource has nothing to cancel). Passing one on aJsonElementreportsCS1929namingHttpResponseMessageas the required receiver, which points at the receiver when the fix is to drop the token.HasJsonTypeInfoFor<T>is now documented as the one assertion in the package that guards a failure class the rest of a suite can miss: a type absent from theJsonSerializerContextis not a compile error, and with a reflection fallback in the resolver chain a JIT test run serializes it happily while the published Native-AOT binary throws at runtime.MatchesProblemDetailsnow documents that it asserts the media type as well as the fields, so a separate content-type test becomes redundant on migration (which a strict analyzer set will flag as duplicate method bodies).
0.6.0 - 2026-07-11: JSON subset matching and typed extraction
Minor release. Adds subset (contains) assertions and typed value extraction. Purely additive.
Added
Assert.That(actual).ContainsJson(expectedSubset)asserts that a JSON document contains an expected subset: every property in the expected document must be present in the actual document with an equivalent value (recursively), while the actual document may carry additional properties. This is the subset counterpart ofIsEquivalentJsonTo(full equality), for the common case where a response carries fields a test does not want to pin. A JSONstring, aJsonElement, and anHttpResponseMessage(body read with a flowedCancellationToken) are accepted as the actual value; the expected subset is a JSONstring. Matching is independent of object-property order and number form. An expected array asserts a positional prefix (the actual array may be longer);IgnoreArrayOrder()matches expected elements against the actual array as a multiset subset, andIgnorePath(...)excludes a path, both through the configure overload. On failure the message lists every missing or mismatched path in one report, each with its category and a rendered view of both sides, so a single run enumerates every field that is wrong.JsonEquivalence.ContainsAll(expected, actual, options)(framework-agnostic core) exposes the subset comparison directly over JSON strings orJsonElements, returning everyJsonDifferencefound (empty when the actual document contains the subset).JsonFailureMessage.ContainsMismatch(differences)renders the multi-difference failure text.GetJsonValue<T>(path),GetJsonString(path), andGetJsonElement(path)read a typed value out of a JSON document at a path and return it, for a test that needs the value (pulling an id to drive the next request, reading a count to cross-check an array length) rather than an assertion.GetJsonValue<T>parses the value as anyIParsable<T>(int/long/bool/Guid/DateTimeOffset, ...) from a JSON string or a JSON number literal, usingCultureInfo.InvariantCulture;GetJsonElementreturns a detached copy that stays valid after the source document is disposed. Each is available over a JSONstring, aJsonElement, and (asGetJsonValueAsync/GetJsonStringAsync/GetJsonElementAsync) anHttpResponseMessage. A failure to resolve the path, convert the value, or parse the source throwsJsonExtractionExceptionwith the same path-context message the assertions render, replacing a hand-rolledJsonDocument.Parse(...).GetProperty(...).GetInt32()chain and its bare BCL exceptions.
0.5.0 - 2026-06-14: structural JSON equivalence
Minor release. Adds whole-document structural equivalence assertions. Purely additive.
Added
Assert.That(actual).IsEquivalentJsonTo(expected)asserts that two JSON documents carry the same values at the same paths. Equivalence ignores object-property order and the lexical form of numbers, so1,1.0, and1e0are equal. Both a JSONstringand aJsonElementare accepted as the actual value; the expected document is a JSONstring. A configure overload sets options:IgnorePath("items[*].timestamp")excludes a path from the comparison (the same path grammar as the other assertions, including the[*]wildcard), andIgnoreArrayOrder()compares arrays as multisets rather than position by position. On failure the message names the first diverging path, the category of difference, and a rendered view of each side, with container values shown as truncated raw JSON.JsonEquivalence(framework-agnostic core) exposes the comparison directly:Compare(expected, actual, options)over JSON strings orJsonElements returns the firstJsonDifference, ornullwhen the documents are equivalent.JsonEquivalenceOptions,JsonDifference, andJsonDifferenceKindare public so the engine is usable outside the assertion path.
0.4.2 - 2026-06-05: documentation refresh
Documentation release. No API or behavior change.
Changed
- Documented the JSON path syntax (dot-separated with
[n]indices plus the[*]wildcard) and why it is a navigable subset rather than JSONPath/JMESPath.
0.4.1 - 2026-06-04: document the [*] wildcard empty-array and index-scoped caveats
Documentation patch. No code, public API, or behavior change.
Changed
[*]wildcard path syntax: documented the empty-array footgun in the root and packed READMEs.[*].idpasses vacuously on an empty array (correct "for all" semantics) while[0].idfails on one, so a naive[0]to[*]migration silently drops the implicit non-emptiness check; the notes tell readers to pair[*]withHasNonEmptyJsonArray(...)when emptiness should fail the test.[*]wildcard path syntax: documented the index-scoped caveat in the root and packed READMEs. Wildcards fit existence and genuinely-uniform value checks only; an element-specific value check (for exampleHasJsonValue("items[2].id", 2)) must stay index-scoped rather than become[*].
0.4.0 - 2026-06-03: [*] wildcard array paths, structural JSON canonicalizer
Feature release. Adds the [*] wildcard path segment so array-element assertions check every element (HasJsonProperty("[*].id"), HasJsonValueMatching("[*].isStarted", ...)) rather than only index [0], turning a weak first-element check into an all-element check. Also adds JsonCanonicalizer.Canonicalize, a typeless structural canonicalizer (sorted keys, stable indent, all fields preserved so new fields surface) with JSON-path scrubbing of volatile values, for composing JSON snapshots with a sibling snapshot package's normalizer hook.
Added
[*]wildcard path segment onHasJsonPropertyandHasJsonValueMatching(backed by the newJsonPath.ResolveAllcore):[*]matches every element of the array it targets, soHasJsonProperty("[*].id")requires every element to carryid, andHasJsonValueMatching("[*].isStarted", v => ...)runs the predicate on each. Nested and multiple wildcards compose (cycles[*].cycleId,[*].tags[*]). An empty array passes vacuously (a "for all" over an empty set; pair with a non-empty-array assertion when emptiness must fail); a failure names which element failed by its concrete index.JsonPath.ContainsWildcard(path)reports whether a path uses the wildcard.JsonCanonicalizer.Canonicalize(string json, Action<JsonCanonicalizeOptions> configure)produces a deterministic structural canonical form: object keys sorted ordinally, two-space indentation, LF line endings, relaxed escaping, and every value preserved (so an added or removed field surfaces as a text diff).JsonCanonicalizeOptions.ScrubPath(path)replaces the value at a JSON path (wildcards supported) with a stable token (default<scrubbed>, overridable viaWithScrubToken). Unlike the typedJsonRenderers.ReformatJson<T>, this needs noJsonSerializerContextand keeps unknown properties, which is what makes it suitable for pinning a whole response shape as a snapshot baseline. Composes with a snapshot library's normalizer hook at the consumer's call site, so neither package depends on the other.int/uint/long/ulongoverloads ofHasJsonValueandHasJsonValueOneOfon a JSONstring, aJsonElement, and anHttpResponseMessage. Each integer overload matches the value whether the JSON encodes it as a number or as a numeric string (parsed withCultureInfo.InvariantCulture), because the encoding depends on the serializer: System.Text.Json writes integers as JSON numbers, while Protobuf'sJsonFormattercan emit them as JSON strings and serializesint64/uint64as strings to avoid the 53-bit precision loss a JSON number would incur. A consumer whose payload carries 64-bit fields (for example a 128-bit id split intohigh/lowhalves) as JSON strings could not match them with thedoubleoverload and had to fall back to thestringoverload; the typed overloads remove that trap by accepting both encodings. A bareintliteral binds to theintoverload, soHasJsonValue("user.age", 30)matches the JSON number exactly; use theL/ULsuffix (HasJsonValue("guid.high", 123456789012345L)) for 64-bit values, and a collection-expression literal for the one-of form (HasJsonValueOneOf("message.sequence", [100L, 200L])). A passing value is an exact integer in range; a fractional number, an out-of-range number, or a non-numeric string fails. Thedoubleoverload is unchanged and matches a JSON number only.JsonValueComparison.Matches/MatchesAnyprimitives forint,uint,long, andulongin theJsonAssertionscore. Each reads the element as an exact integer from either a JSON number (viaTryGetInt32/TryGetUInt32/TryGetInt64/TryGetUInt64, so a fractional or out-of-range number returnsfalse) or a numeric JSON string parsed withCultureInfo.InvariantCulture. A kind or parse mismatch returnsfalserather than throwing, matching the existingMatches/MatchesAnycontract.
0.3.0 - 2026-05-17: AOT-context regression assertions, HTTP-response JSON and RFC 7807 ProblemDetails, a canonicalizing JSON renderer for snapshot composition, plus a public failure-message extension point
Added
- Added
RoundtripsCleanlyVia<T>(this T value, JsonTypeInfo<T> jsonTypeInfo)as an extension method on any valueT, asserting the value serializes via the suppliedJsonTypeInfo<T>, deserializes back, and re-serializes to a byte-identical JSON string. Catches the common "added a property and forgot to update theJsonSerializerContext" regression class at CI time. AOT-clean; failure messages render both serialized strings side-by-side for diagnosis. - Added
HasJsonTypeInfoFor<T>()and theAsJsonContext()bridge onIAssertionSource<TContext>whereTContext : JsonSerializerContext. The leaf assertion asserts the underlying context registers aJsonTypeInfo<T>forT, catching the "added a domain type but forgot the[JsonSerializable(typeof(NewType))]" regression class. The educational-demand AOT-moat companion toRoundtripsCleanlyVia: where that assertion verifies a value round-trips cleanly through a typed context,HasJsonTypeInfoForverifies the context knows about the type at all. The bridge extensionAsJsonContext()produces anIJsonContextAssertionSourcewith the context viewed at theJsonSerializerContextbase, 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)onHttpResponseMessage, combining HTTP status + AOT-clean deserialization + structural-equality in one chain. Collapses the commonresponse.EnsureSuccessStatusCode() + body-read + Deserialize + AreEqual4-6-line pattern into a single fluent call. The suppliedJsonTypeInfo<T>is the source-generated entry from the consumer'sJsonSerializerContext; 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)onHttpResponseMessage, asserting the response is a valid RFC 7807 ProblemDetails (Content-Typeapplication/problem+json, deserializable shape) and that each specified field matches. Unspecified fields skip (passnull). Deserializes via an internalProblemDetailsMirrorso the production package stays MIT-clean (noMicrosoft.AspNetCore.Mvc.AbstractionsApache 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 section 3.2 extension members are captured by the mirror's[JsonExtensionData]dictionary so they survive deserialization; a futureWithExtension(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)onHttpResponseMessage. Same shape asMatchesProblemDetailsplus the ASP.NET Core ValidationProblemDetailserrorsdictionary mapping field names to validation messages. Every key in the suppliederrorsdictionary must appear in the response with matching values. - Added
JsonRenderers.ReformatJson<T>(JsonTypeInfo<T>)in theJsonAssertionscore namespace as a static factory returningFunc<string, string>that canonicalizes a JSON string via the consumer'sJsonSerializerContext(deserialize then re-serialize through the suppliedJsonTypeInfo<T>). Composes withSnapshotAssertions.TUnit'sMatchesSnapshot(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 canonicalize + snapshot. AOT-clean by construction. - Promoted
JsonFailureMessagestatic class in theJsonAssertionscore namespace frominternaltopublic. 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 theMathAssertions.MathFailureMessagepattern in the sibling package. HTTP-response, ProblemDetails, and roundtrip-specific factories remaininternal- context-bound to their specific assertions, no extension value.
Changed
- Changed
RoundtripsCleanlyVia<T>to acceptnullpayloads. 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
MatchesProblemDetailsandMatchesValidationProblemDetailscontent-type comparison to be case-insensitive (RFC 9110 section 8.3.2 media-type tokens). A response withApplication/Problem+Jsonor any other case variant now correctly satisfies the RFC 7807 content-type check, where the previous ordinal comparison required exactapplication/problem+json.
0.2.0 - 2026-05-15: Array-indexed paths, root-self, boolean / non-empty-string / matching / one-of / parsable-as assertions
Added
- Array-indexed path segments.
JsonPath.Resolve(element, "items[0].name")navigatesitems(object property) then index 0 (array element) thenname(object property). Indices are zero-based, non-negative integers in[N]brackets. Mixed property + index segments compose freely (objects[0].entries[1].id). Closes the #1 friction surfaced by the v0.1.0 adoption survey. $JSONPath root-self.JsonPath.Resolve(element, "$")resolves to the supplied element itself.$.user.nameis equivalent touser.name;$[0]is equivalent to[0]. A bare[0]against a root array also works without the$prefix. Closes the "no path to assert root-array shape" gap surfaced by the v0.1.0 adoption survey.HasNonEmptyJsonString(path)onstringandJsonElement(andHttpResponseMessagefor the async HTTP entry point). Asserts the value atpathis a JSON string of non-zero length. A non-string kind or an empty""string fails.HasJsonBoolean(path)onstring,JsonElement, andHttpResponseMessage. Asserts the value atpathis a JSON boolean (eithertrueorfalse).JsonValueKind.Trueand.Falseare distinct kinds, so this is the discoverable form of "this field is a bool, either value" thatHasJsonValueKindalone cannot express.HasJsonValueMatching(path, Func<JsonElement, bool> predicate)onstring,JsonElement, andHttpResponseMessage. Asserts the value atpathsatisfies a consumer-supplied predicate. Covers the ~1/4 of value assertions that are not exact-equality (numeric inequalities, complex shape checks).HasJsonValueOneOf(path, string[])andHasJsonValueOneOf(path, double[])onstring,JsonElement, andHttpResponseMessage. The discoverable form for "value is one of {Healthy, Degraded, Unhealthy}" or "code is one of {200, 503, 504}". Callers pass a C# 12 collection-expression literal:HasJsonValueOneOf("status", ["Healthy", "Degraded"]).HasJsonValueParsableAs<T>(path) where T : IParsable<T>onstring,JsonElement, andHttpResponseMessage. Asserts the value atpathis a JSON string whose text parses asTviaT.TryParse(value, CultureInfo.InvariantCulture, out _). Covers the "value parses asGuid/DateTimeOffset/Uri" pattern without committing to a particular parser per call site.JsonShape.IsNonEmptyString(JsonElement)andJsonShape.IsBoolean(JsonElement)framework-agnostic predicates in theJsonAssertionscore, matching the family pattern (core predicate + TUnit-adapter assertion).JsonValueComparison.MatchesAny(JsonElement, string[])andJsonValueComparison.MatchesAny(JsonElement, double[])framework-agnostic comparison primitives in theJsonAssertionscore.
Changed
JsonPath.Resolvefailure-point context for array failures. An out-of-range index on an array, or an index access on a non-array, now surfaces inFailedSegmentas[N](matching the resolved-prefix syntax) and renders a tailored reason line in the failure message:no element at index [N] on "items"for an array out-of-range;cannot index [N]: "user" is an Object, not an arrayfor an index access on a non-array.
0.1.0 - 2026-05-14: Value-at-path and shape assertions, an HTTP entry point, and path-context diagnostics
The first feature release. 0.1.0 turns the 0.0.1 skeleton into a real assertion surface: value-at-path assertions, shape assertions (array length, non-empty / empty array, value kind), and a first-class HttpResponseMessage entry point. Every assertion's failure message is rebuilt to say where on the path resolution stopped. That path-context diagnostic is the load-bearing reason this is a package rather than a hand-rolled TryGetProperty(...).IsTrue() helper.
Added
JsonPath.Resolve(JsonElement, string)returns aJsonPathResolutionthat carries the resolved element on success, and the failure-point context on failure: how far the path resolved (ResolvedPrefix), which segment could not be resolved (FailedSegment), and theJsonValueKindof the element that blocked it (ContainerKind, which distinguishes "the object has no such property" from "the path tried to read a property off a non-object").JsonPathResolutionreadonly record struct carrying that outcome.JsonValueComparison.Matchesoverloads forstring,bool, anddouble(numeric) expected values. A kind mismatch returnsfalserather than throwing, so a caller can render a "found a String, expected a Number" diagnostic. NamedJsonValueComparisonrather thanJsonValueto avoid colliding withSystem.Text.Json.Nodes.JsonValue.JsonShapeshape predicates:IsKind,IsArrayOfLength,IsNonEmptyArray,IsEmptyArray. Kind mismatches returnfalserather than throwing.HasJsonValue(path, expected)asserts the value at a dot-separated path. Overloads accept astring, abool, or a number (double;intandlongliterals widen at the call site), over both a JSONstringand aJsonElement.- Shape assertions
HasJsonArrayLength(path, length),HasNonEmptyJsonArray(path),HasEmptyJsonArray(path), andHasJsonValueKind(path, kind), over both a JSONstringand aJsonElement. On failure the message shows the found shape (an array reports its length; any other kind reports its kind). HttpResponseMessageentry point. Every property / value / shape assertion is also available directly on anHttpResponseMessage: the response body is read as text and the assertion runs against it, so a test can writeawait Assert.That(response).HasJsonProperty("user.name", ct)without a separate read-and-parse step. The methods are asynchronous and take an optionalCancellationToken(defaultCancellationToken.None) that flows to the body read; the body covers only the JSON payload (status-code assertions are out of scope).- Malformed JSON now fails the assertion with an explained message ("the asserted value to be parseable JSON / but parsing failed: ...") rather than escaping as a raw
JsonException. This applies to every JSON-stringandHttpResponseMessageoverload. A body that cannot be parsed does not vacuously satisfyDoesNotHaveJsonProperty.
Changed
HasJsonProperty/DoesNotHaveJsonPropertynow returnAssertionResultinstead ofbool. On failure they render a path-context block:resolved as far as:(the longest prefix that resolved) followed by a reason line. The generated TUnit chain extensions (Assert.That(json).HasJsonProperty(path)) are unaffected at the chain-syntax level.JsonPath.Exists(thebool-returning core shorthand) is unchanged.- TUnit dependency bumped
1.44.0->1.44.39. The 1.44.39 release fixes the[GenerateAssertion]source generator emitting an invalid= nulldefault for value-type optional parameters, which lets theHttpResponseMessageoverloads take an optionalCancellationToken ct = defaultin line with the familyCancellationToken-plumbing convention.
0.0.1 - 2026-05-14: Initial preview, skeleton release establishing repository, package identifier, and quality bar
First public release. One package: JsonAssertions.TUnit, a TUnit-native JSON assertion library built on System.Text.Json. .NET 10, AOT-compatible, trimmable, no runtime reflection in the assertion path.
The 0.0.1 scope is intentionally narrow. The release exists to establish the repository, claim the JsonAssertions.TUnit package identifier on nuget.org, and lock the API style and quality bar before the wider catalog ships at 0.1.0. Consumers needing the full v0.1.0 surface can install 0.0.1 to lock the dependency relationship and watch the CHANGELOG.
Added
JsonPath.Exists(JsonElement, string): navigates a dot-separated property path from aJsonElementand reports whether a property exists at it. A leading$.JSONPath-style root prefix is accepted and ignored. A path that traverses a non-object value resolves to "not found" rather than throwing. An empty path, a whitespace path, or a path with an empty segment throwsArgumentException.HasJsonProperty(path): fluent entry point asserting a property exists at the dot-separatedpath, generated via TUnit's[GenerateAssertion]. Available on a JSONstringand on aSystem.Text.Json.JsonElement.DoesNotHaveJsonProperty(path): the negative form, asserting no property exists at the path. Available on the same two input types.
Both namespaces ship in the single JsonAssertions.TUnit assembly. The two-namespace split keeps the same consumer feel as the rest of the assertion family (a framework-agnostic core plus a TUnit adapter) and future-proofs a package split if the bare JsonAssertions identifier ever becomes available.