From 699cea25fc0a075f6dafec9588a565f550d0cd68 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 11:51:06 +0000 Subject: [PATCH 01/10] feat(assertions): add JsonDiffHelper for path-to-difference error messages --- .../Conditions/Json/JsonDiffHelper.cs | 133 ++++++++++++++++++ TUnit.Assertions/TUnit.Assertions.csproj | 1 + 2 files changed, 134 insertions(+) create mode 100644 TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs diff --git a/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs new file mode 100644 index 0000000000..6816e7ffd9 --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs @@ -0,0 +1,133 @@ +using System.Text.Json; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Helper class for comparing JSON elements and identifying differences. +/// +internal static class JsonDiffHelper +{ + /// + /// Represents the result of a JSON comparison, including the path where a difference was found + /// and the expected and actual values at that location. + /// + /// The JSON path where the difference was found (e.g., "$.person.name"). + /// The expected value at this path. + /// The actual value at this path. + /// Whether a difference was found. Defaults to true. + public readonly record struct DiffResult(string Path, string Expected, string Actual, bool HasDifference = true); + + /// + /// Finds the first difference between two JSON elements. + /// + /// The actual JSON element to compare. + /// The expected JSON element to compare against. + /// A containing information about the first difference found, + /// or a result with set to false if the elements are identical. + public static DiffResult FindFirstDifference(JsonElement actual, JsonElement expected) + { + return FindDiff(actual, expected, "$"); + } + + private static DiffResult FindDiff(JsonElement actual, JsonElement expected, string path) + { + if (actual.ValueKind != expected.ValueKind) + { + return new DiffResult(path, expected.ValueKind.ToString(), actual.ValueKind.ToString()); + } + + return actual.ValueKind switch + { + JsonValueKind.Object => CompareObjects(actual, expected, path), + JsonValueKind.Array => CompareArrays(actual, expected, path), + _ => ComparePrimitives(actual, expected, path) + }; + } + + private static DiffResult CompareObjects(JsonElement actual, JsonElement expected, string path) + { + // Check for missing properties in actual that exist in expected + foreach (var prop in expected.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!actual.TryGetProperty(prop.Name, out var actualProp)) + { + return new DiffResult(propPath, FormatValue(prop.Value), "(missing)"); + } + + var diff = FindDiff(actualProp, prop.Value, propPath); + if (diff.HasDifference) + { + return diff; + } + } + + // Check for extra properties in actual that don't exist in expected + foreach (var prop in actual.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!expected.TryGetProperty(prop.Name, out _)) + { + return new DiffResult(propPath, "(missing)", FormatValue(prop.Value)); + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareArrays(JsonElement actual, JsonElement expected, string path) + { + var actualLength = actual.GetArrayLength(); + var expectedLength = expected.GetArrayLength(); + + if (actualLength != expectedLength) + { + return new DiffResult($"{path}.Length", expectedLength.ToString(), actualLength.ToString()); + } + + var actualEnumerator = actual.EnumerateArray(); + var expectedEnumerator = expected.EnumerateArray(); + var index = 0; + + while (actualEnumerator.MoveNext() && expectedEnumerator.MoveNext()) + { + var itemPath = $"{path}[{index}]"; + var diff = FindDiff(actualEnumerator.Current, expectedEnumerator.Current, itemPath); + if (diff.HasDifference) + { + return diff; + } + index++; + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult ComparePrimitives(JsonElement actual, JsonElement expected, string path) + { + var actualText = FormatValue(actual); + var expectedText = FormatValue(expected); + + if (actualText != expectedText) + { + return new DiffResult(path, expectedText, actualText); + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static string FormatValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => $"\"{element.GetString()}\"", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Object => "{...}", + JsonValueKind.Array => "[...]", + _ => element.GetRawText() + }; + } +} diff --git a/TUnit.Assertions/TUnit.Assertions.csproj b/TUnit.Assertions/TUnit.Assertions.csproj index faebe00897..e2fb098ee5 100644 --- a/TUnit.Assertions/TUnit.Assertions.csproj +++ b/TUnit.Assertions/TUnit.Assertions.csproj @@ -54,6 +54,7 @@ + From 876a6c0b40dafe400ba69b7ef84a598ac14f3278 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 12:10:29 +0000 Subject: [PATCH 02/10] feat(assertions): add JsonElement type checking assertions --- .../JsonElementAssertionTests.cs | 112 ++ .../Json/JsonElementAssertionExtensions.cs | 38 + .../2025-12-28-json-assertions-design.md | 230 ++++ docs/plans/2025-12-28-json-assertions.md | 991 ++++++++++++++++++ 4 files changed, 1371 insertions(+) create mode 100644 TUnit.Assertions.Tests/JsonElementAssertionTests.cs create mode 100644 TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs create mode 100644 docs/plans/2025-12-28-json-assertions-design.md create mode 100644 docs/plans/2025-12-28-json-assertions.md diff --git a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs new file mode 100644 index 0000000000..871873c112 --- /dev/null +++ b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonElementAssertionTests +{ + [Test] + public async Task IsObject_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"test\"}"); + await Assert.That(doc.RootElement).IsObject(); + } + + [Test] + public async Task IsObject_WithArray_Fails() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsObject()); + } + + [Test] + public async Task IsArray_WithArray_Passes() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.That(doc.RootElement).IsArray(); + } + + [Test] + public async Task IsArray_WithNonArray_Fails() + { + using var doc = JsonDocument.Parse("{\"key\":\"value\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsArray()); + } + + [Test] + public async Task IsString_WithString_Passes() + { + using var doc = JsonDocument.Parse("\"hello\""); + await Assert.That(doc.RootElement).IsString(); + } + + [Test] + public async Task IsString_WithNonString_Fails() + { + using var doc = JsonDocument.Parse("42"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsString()); + } + + [Test] + public async Task IsNumber_WithNumber_Passes() + { + using var doc = JsonDocument.Parse("42"); + await Assert.That(doc.RootElement).IsNumber(); + } + + [Test] + public async Task IsNumber_WithNonNumber_Fails() + { + using var doc = JsonDocument.Parse("\"text\""); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNumber()); + } + + [Test] + public async Task IsBoolean_WithTrue_Passes() + { + using var doc = JsonDocument.Parse("true"); + await Assert.That(doc.RootElement).IsBoolean(); + } + + [Test] + public async Task IsBoolean_WithNonBoolean_Fails() + { + using var doc = JsonDocument.Parse("null"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsBoolean()); + } + + [Test] + public async Task IsNull_WithNull_Passes() + { + using var doc = JsonDocument.Parse("null"); + await Assert.That(doc.RootElement).IsNull(); + } + + [Test] + public async Task IsNull_WithNonNull_Fails() + { + using var doc = JsonDocument.Parse("{}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNull()); + } + + [Test] + public async Task IsNotNull_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{}"); + await Assert.That(doc.RootElement).IsNotNull(); + } + + [Test] + public async Task IsNotNull_WithNull_Fails() + { + using var doc = JsonDocument.Parse("null"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsNotNull()); + } +} diff --git a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs new file mode 100644 index 0000000000..6b4ba1d018 --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonElement type checking. +/// +file static partial class JsonElementAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] + public static bool IsObject(this JsonElement value) + => value.ValueKind == JsonValueKind.Object; + + [GenerateAssertion(ExpectationMessage = "to be a JSON array", InlineMethodBody = true)] + public static bool IsArray(this JsonElement value) + => value.ValueKind == JsonValueKind.Array; + + [GenerateAssertion(ExpectationMessage = "to be a JSON string", InlineMethodBody = true)] + public static bool IsString(this JsonElement value) + => value.ValueKind == JsonValueKind.String; + + [GenerateAssertion(ExpectationMessage = "to be a JSON number", InlineMethodBody = true)] + public static bool IsNumber(this JsonElement value) + => value.ValueKind == JsonValueKind.Number; + + [GenerateAssertion(ExpectationMessage = "to be a JSON boolean", InlineMethodBody = true)] + public static bool IsBoolean(this JsonElement value) + => value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False; + + [GenerateAssertion(ExpectationMessage = "to be JSON null", InlineMethodBody = true)] + public static bool IsNull(this JsonElement value) + => value.ValueKind == JsonValueKind.Null; + + [GenerateAssertion(ExpectationMessage = "to not be JSON null", InlineMethodBody = true)] + public static bool IsNotNull(this JsonElement value) + => value.ValueKind != JsonValueKind.Null; +} diff --git a/docs/plans/2025-12-28-json-assertions-design.md b/docs/plans/2025-12-28-json-assertions-design.md new file mode 100644 index 0000000000..40f3b62377 --- /dev/null +++ b/docs/plans/2025-12-28-json-assertions-design.md @@ -0,0 +1,230 @@ +# JSON Assertions Design + +**Issue**: [#4178 - JsonElement assertions](https://github.com/thomhurst/TUnit/issues/4178) +**Date**: 2025-12-28 +**Status**: Approved Design + +## Summary + +Add comprehensive JSON assertions to TUnit supporting both `JsonElement` and `JsonNode` type hierarchies, with runtime-appropriate features via conditional compilation. + +## Problem Statement + +Currently, `Assert.That(json1).IsEqualTo(json2)` performs string comparison on JSON, which: +- Fails on semantically equivalent JSON with different whitespace +- Provides unhelpful error messages that don't indicate where differences occur + +Users need: +- Semantic JSON comparison (ignoring formatting) +- Detailed error messages with paths to differences (e.g., "differs at $.abc.def") + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Target Types | Both JsonElement and JsonNode | Complete coverage for all System.Text.Json users | +| Equality Logic | Built-in `DeepEquals` | Leverage .NET's tested implementation | +| Runtime Support | Per-runtime via `#if` | Provide what's available without polyfills | +| Assertion Categories | Equality, Validity, Property, Type, Array | Comprehensive without external dependencies | +| Path Queries | Deferred | JSONPath not built-in, avoid dependencies | +| Error Messages | Path-to-difference | Addresses core issue request | +| API Entry Point | `Assert.That()` extensions | Consistent with TUnit patterns | +| Package Location | Inside TUnit.Assertions | No extra package for users | +| Implementation | `[GenerateAssertion]` | Simplified code, source generator handles infrastructure | + +## Runtime Availability Matrix + +| Assertion | .NET 6-7 | .NET 8 | .NET 9+ | +|-----------|----------|--------|---------| +| `IsEqualTo` (JsonNode) | - | Y | Y | +| `IsEqualTo` (JsonElement) | - | - | Y | +| `IsValidJson` (string) | Y | Y | Y | +| `HasProperty` | Y | Y | Y | +| Type checks (`IsObject`, etc.) | Y | Y | Y | +| Array assertions | Y | Y | Y | + +## File Structure + +``` +TUnit.Assertions/Conditions/Json/ +├── JsonElementAssertionExtensions.cs +├── JsonNodeAssertionExtensions.cs +├── JsonStringAssertionExtensions.cs +└── JsonDiffHelper.cs +``` + +## Assertion Inventory + +### JsonElement Assertions + +```csharp +// Type checking (all runtimes) +IsObject() +IsArray() +IsString() +IsNumber() +IsBoolean() +IsNull() +IsNotNull() + +// Property access (all runtimes) +HasProperty(string propertyName) +DoesNotHaveProperty(string propertyName) + +// Equality (.NET 9+ only) +#if NET9_0_OR_GREATER +IsEqualTo(JsonElement expected) +IsNotEqualTo(JsonElement expected) +#endif +``` + +### JsonNode Assertions + +```csharp +// Type checking (all runtimes) +IsObject() +IsArray() +IsValue() + +// Property access (all runtimes) +HasProperty(string propertyName) +DoesNotHaveProperty(string propertyName) + +// Equality (.NET 8+ only) +#if NET8_0_OR_GREATER +IsEqualTo(JsonNode? expected) +IsNotEqualTo(JsonNode? expected) +#endif +``` + +### JsonArray Assertions + +```csharp +// All runtimes +IsEmpty() +IsNotEmpty() +HasCount(int expected) +``` + +### String (Raw JSON) Assertions + +```csharp +// All runtimes +IsValidJson() +IsNotValidJson() +IsValidJsonObject() +IsValidJsonArray() +``` + +## Implementation Pattern + +Using `[GenerateAssertion]` for simplified code: + +```csharp +using System.Text.Json; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Json; + +file static partial class JsonElementAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] + public static bool IsObject(this JsonElement value) + => value.ValueKind == JsonValueKind.Object; + + [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"")] + public static bool HasProperty(this JsonElement value, string propertyName) + => value.TryGetProperty(propertyName, out _); + +#if NET9_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] + public static AssertionResult IsEqualTo(this JsonElement value, JsonElement expected) + { + if (JsonElement.DeepEquals(value, expected)) + return AssertionResult.Passed; + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); + } +#endif +} +``` + +## JsonDiffHelper + +Provides path-to-difference for error messages: + +```csharp +internal static class JsonDiffHelper +{ + public readonly record struct DiffResult(string Path, string Expected, string Actual); + + public static DiffResult FindFirstDifference(JsonElement left, JsonElement right) + { + return FindDiff(left, right, "$"); + } + + private static DiffResult FindDiff(JsonElement left, JsonElement right, string path) + { + if (left.ValueKind != right.ValueKind) + return new DiffResult(path, right.ValueKind.ToString(), left.ValueKind.ToString()); + + return left.ValueKind switch + { + JsonValueKind.Object => CompareObjects(left, right, path), + JsonValueKind.Array => CompareArrays(left, right, path), + _ => ComparePrimitives(left, right, path) + }; + } + // ... implementation details +} +``` + +## Error Message Examples + +``` +Expected JSON to be equal to {"name":"Alice","age":31} +but differs at $.age: expected 31 but found 30 + +Expected JSON to be equal to {"users":[{"id":1}]} +but differs at $.users[0].id: expected 1 but found 2 + +Expected JSON to be equal to {"active":true} +but differs at $.active: expected True but found False +``` + +## Usage Examples + +```csharp +// Type checking +await Assert.That(element).IsObject(); +await Assert.That(node).IsArray(); + +// Property access +await Assert.That(element).HasProperty("name"); +await Assert.That(element).DoesNotHaveProperty("deleted"); + +// Equality (.NET 8+/9+) +await Assert.That(element).IsEqualTo(expectedElement); +await Assert.That(node).IsEqualTo(expectedNode); + +// String validation +await Assert.That(jsonString).IsValidJson(); +await Assert.That(jsonString).IsValidJsonObject(); +``` + +## Testing Strategy + +- Unit tests in `TUnit.Assertions.Tests` with `#if` for runtime-specific tests +- Test both pass and fail scenarios for each assertion +- Verify error message format includes correct JSON paths +- Multi-target test project to validate behavior on each runtime + +## Future Considerations + +- JSONPath support via optional dependency or built-in simple path syntax +- `HasPropertyWithValue(name, value)` combined assertion +- JSON Schema validation +- Partial matching / subset assertions diff --git a/docs/plans/2025-12-28-json-assertions.md b/docs/plans/2025-12-28-json-assertions.md new file mode 100644 index 0000000000..7c728655a2 --- /dev/null +++ b/docs/plans/2025-12-28-json-assertions.md @@ -0,0 +1,991 @@ +# JSON Assertions Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add comprehensive JSON assertions for JsonElement, JsonNode, and string types with semantic equality and path-to-difference error messages. + +**Architecture:** Use `[GenerateAssertion]` attribute pattern for all assertions. Conditional compilation (`#if NET8_0_OR_GREATER`, `#if NET9_0_OR_GREATER`) enables runtime-specific features. JsonDiffHelper provides detailed error messages showing where JSON structures differ. + +**Tech Stack:** System.Text.Json (built-in), TUnit.Assertions source generator, C# 12 + +--- + +## Task 1: Create JsonDiffHelper + +**Files:** +- Create: `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs` + +**Step 1: Create the Json directory** + +```bash +mkdir TUnit.Assertions/Conditions/Json +``` + +**Step 2: Write the JsonDiffHelper** + +Create `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs`: + +```csharp +using System.Text.Json; + +namespace TUnit.Assertions.Conditions.Json; + +internal static class JsonDiffHelper +{ + public readonly record struct DiffResult(string Path, string Expected, string Actual); + + public static DiffResult FindFirstDifference(JsonElement left, JsonElement right) + { + return FindDiff(left, right, "$"); + } + + private static DiffResult FindDiff(JsonElement left, JsonElement right, string path) + { + if (left.ValueKind != right.ValueKind) + { + return new DiffResult(path, right.ValueKind.ToString(), left.ValueKind.ToString()); + } + + return left.ValueKind switch + { + JsonValueKind.Object => CompareObjects(left, right, path), + JsonValueKind.Array => CompareArrays(left, right, path), + _ => ComparePrimitives(left, right, path) + }; + } + + private static DiffResult CompareObjects(JsonElement left, JsonElement right, string path) + { + // Check for missing properties in left that exist in right + foreach (var prop in right.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!left.TryGetProperty(prop.Name, out var leftProp)) + { + return new DiffResult(propPath, FormatValue(prop.Value), "(missing)"); + } + + var diff = FindDiff(leftProp, prop.Value, propPath); + if (!string.IsNullOrEmpty(diff.Expected) || !string.IsNullOrEmpty(diff.Actual)) + { + return diff; + } + } + + // Check for extra properties in left that don't exist in right + foreach (var prop in left.EnumerateObject()) + { + var propPath = $"{path}.{prop.Name}"; + if (!right.TryGetProperty(prop.Name, out _)) + { + return new DiffResult(propPath, "(missing)", FormatValue(prop.Value)); + } + } + + return default; + } + + private static DiffResult CompareArrays(JsonElement left, JsonElement right, string path) + { + var leftLength = left.GetArrayLength(); + var rightLength = right.GetArrayLength(); + + if (leftLength != rightLength) + { + return new DiffResult($"{path}.Length", rightLength.ToString(), leftLength.ToString()); + } + + var leftEnumerator = left.EnumerateArray(); + var rightEnumerator = right.EnumerateArray(); + var index = 0; + + while (leftEnumerator.MoveNext() && rightEnumerator.MoveNext()) + { + var itemPath = $"{path}[{index}]"; + var diff = FindDiff(leftEnumerator.Current, rightEnumerator.Current, itemPath); + if (!string.IsNullOrEmpty(diff.Expected) || !string.IsNullOrEmpty(diff.Actual)) + { + return diff; + } + index++; + } + + return default; + } + + private static DiffResult ComparePrimitives(JsonElement left, JsonElement right, string path) + { + var leftText = FormatValue(left); + var rightText = FormatValue(right); + + if (leftText != rightText) + { + return new DiffResult(path, rightText, leftText); + } + + return default; + } + + private static string FormatValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => $"\"{element.GetString()}\"", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Object => "{...}", + JsonValueKind.Array => "[...]", + _ => element.GetRawText() + }; + } +} +``` + +**Step 3: Verify it compiles** + +```bash +cd C:/git/TUnit && dotnet build TUnit.Assertions/TUnit.Assertions.csproj --no-restore -v q +``` + +Expected: Build succeeded + +**Step 4: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs +git commit -m "feat(assertions): add JsonDiffHelper for path-to-difference error messages" +``` + +--- + +## Task 2: Create JsonElement Type Assertions + +**Files:** +- Create: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` +- Test: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` + +**Step 1: Write the failing test** + +Create `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: + +```csharp +using System.Text.Json; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonElementAssertionTests +{ + [Test] + public async Task IsObject_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"test\"}"); + await Assert.That(doc.RootElement).IsObject(); + } + + [Test] + public async Task IsObject_WithArray_Fails() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).IsObject()); + } + + [Test] + public async Task IsArray_WithArray_Passes() + { + using var doc = JsonDocument.Parse("[1,2,3]"); + await Assert.That(doc.RootElement).IsArray(); + } + + [Test] + public async Task IsString_WithString_Passes() + { + using var doc = JsonDocument.Parse("\"hello\""); + await Assert.That(doc.RootElement).IsString(); + } + + [Test] + public async Task IsNumber_WithNumber_Passes() + { + using var doc = JsonDocument.Parse("42"); + await Assert.That(doc.RootElement).IsNumber(); + } + + [Test] + public async Task IsBoolean_WithTrue_Passes() + { + using var doc = JsonDocument.Parse("true"); + await Assert.That(doc.RootElement).IsBoolean(); + } + + [Test] + public async Task IsNull_WithNull_Passes() + { + using var doc = JsonDocument.Parse("null"); + await Assert.That(doc.RootElement).IsNull(); + } + + [Test] + public async Task IsNotNull_WithObject_Passes() + { + using var doc = JsonDocument.Parse("{}"); + await Assert.That(doc.RootElement).IsNotNull(); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" --no-build +``` + +Expected: FAIL - IsObject method not found + +**Step 3: Write the JsonElement type assertions** + +Create `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: + +```csharp +using System.Text.Json; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonElement type checking. +/// +file static partial class JsonElementAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] + public static bool IsObject(this JsonElement value) + => value.ValueKind == JsonValueKind.Object; + + [GenerateAssertion(ExpectationMessage = "to be a JSON array", InlineMethodBody = true)] + public static bool IsArray(this JsonElement value) + => value.ValueKind == JsonValueKind.Array; + + [GenerateAssertion(ExpectationMessage = "to be a JSON string", InlineMethodBody = true)] + public static bool IsString(this JsonElement value) + => value.ValueKind == JsonValueKind.String; + + [GenerateAssertion(ExpectationMessage = "to be a JSON number", InlineMethodBody = true)] + public static bool IsNumber(this JsonElement value) + => value.ValueKind == JsonValueKind.Number; + + [GenerateAssertion(ExpectationMessage = "to be a JSON boolean", InlineMethodBody = true)] + public static bool IsBoolean(this JsonElement value) + => value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False; + + [GenerateAssertion(ExpectationMessage = "to be JSON null", InlineMethodBody = true)] + public static bool IsNull(this JsonElement value) + => value.ValueKind == JsonValueKind.Null; + + [GenerateAssertion(ExpectationMessage = "to not be JSON null", InlineMethodBody = true)] + public static bool IsNotNull(this JsonElement value) + => value.ValueKind != JsonValueKind.Null; +} +``` + +**Step 4: Build to generate extension methods** + +```bash +cd C:/git/TUnit && dotnet build TUnit.Assertions/TUnit.Assertions.csproj -v q +``` + +Expected: Build succeeded + +**Step 5: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" +``` + +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs +git commit -m "feat(assertions): add JsonElement type checking assertions" +``` + +--- + +## Task 3: Add JsonElement Property Assertions + +**Files:** +- Modify: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` +- Modify: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` + +**Step 1: Write the failing tests** + +Add to `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: + +```csharp + [Test] + public async Task HasProperty_WhenPropertyExists_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc.RootElement).HasProperty("name"); + } + + [Test] + public async Task HasProperty_WhenPropertyMissing_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).HasProperty("missing")); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc.RootElement).DoesNotHaveProperty("missing"); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyExists_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).DoesNotHaveProperty("name")); + } +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests.HasProperty" --no-build +``` + +Expected: FAIL - HasProperty method not found + +**Step 3: Add property assertions** + +Add to `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: + +```csharp + [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"", InlineMethodBody = true)] + public static bool HasProperty(this JsonElement value, string propertyName) + => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(propertyName, out _); + + [GenerateAssertion(ExpectationMessage = "to not have property \"{propertyName}\"", InlineMethodBody = true)] + public static bool DoesNotHaveProperty(this JsonElement value, string propertyName) + => value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(propertyName, out _); +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" +``` + +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs +git commit -m "feat(assertions): add JsonElement property assertions" +``` + +--- + +## Task 4: Add JsonElement Equality Assertions (.NET 9+) + +**Files:** +- Modify: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` +- Modify: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` + +**Step 1: Write the failing tests** + +Add to `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: + +```csharp +#if NET9_0_OR_GREATER + [Test] + public async Task IsEqualTo_WithIdenticalJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsEqualTo_WithDifferentWhitespace_Passes() + { + using var doc1 = JsonDocument.Parse("{ \"name\" : \"Alice\" }"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsEqualTo_WithDifferentValues_FailsWithPath() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":31}"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement)); + + await Assert.That(exception.Message).Contains("$.age"); + } + + [Test] + public async Task IsNotEqualTo_WithDifferentJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Bob\"}"); + await Assert.That(doc1.RootElement).IsNotEqualTo(doc2.RootElement); + } +#endif +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests.IsEqualTo" --framework net9.0 --no-build +``` + +Expected: FAIL - IsEqualTo method not found + +**Step 3: Add equality assertions** + +Add to `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: + +```csharp +#if NET9_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsEqualTo(this JsonElement value, JsonElement expected) + { + if (JsonElement.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return TUnit.Assertions.Core.AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); + } + + [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}", InlineMethodBody = true)] + public static bool IsNotEqualTo(this JsonElement value, JsonElement expected) + => !JsonElement.DeepEquals(value, expected); +#endif +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" --framework net9.0 +``` + +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs +git commit -m "feat(assertions): add JsonElement equality assertions for .NET 9+" +``` + +--- + +## Task 5: Create JsonNode Assertions + +**Files:** +- Create: `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` +- Create: `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` + +**Step 1: Write the failing test** + +Create `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs`: + +```csharp +using System.Text.Json.Nodes; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonNodeAssertionTests +{ + [Test] + public async Task IsObject_WithJsonObject_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.That(node).IsJsonObject(); + } + + [Test] + public async Task IsArray_WithJsonArray_Passes() + { + JsonNode? node = JsonNode.Parse("[1,2,3]"); + await Assert.That(node).IsJsonArray(); + } + + [Test] + public async Task IsValue_WithJsonValue_Passes() + { + JsonNode? node = JsonNode.Parse("42"); + await Assert.That(node).IsJsonValue(); + } + + [Test] + public async Task HasProperty_WhenPropertyExists_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).HasJsonProperty("name"); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).DoesNotHaveJsonProperty("missing"); + } + +#if NET8_0_OR_GREATER + [Test] + public async Task IsEqualTo_WithIdenticalJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(node1).IsEqualTo(node2); + } + + [Test] + public async Task IsNotEqualTo_WithDifferentJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); + await Assert.That(node1).IsNotEqualTo(node2); + } +#endif +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" --no-build +``` + +Expected: FAIL - methods not found + +**Step 3: Write the JsonNode assertions** + +Create `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs`: + +```csharp +using System.Text.Json.Nodes; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonNode types. +/// +file static partial class JsonNodeAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JsonObject", InlineMethodBody = true)] + public static bool IsJsonObject(this JsonNode? value) + => value is JsonObject; + + [GenerateAssertion(ExpectationMessage = "to be a JsonArray", InlineMethodBody = true)] + public static bool IsJsonArray(this JsonNode? value) + => value is JsonArray; + + [GenerateAssertion(ExpectationMessage = "to be a JsonValue", InlineMethodBody = true)] + public static bool IsJsonValue(this JsonNode? value) + => value is JsonValue; + + [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"", InlineMethodBody = true)] + public static bool HasJsonProperty(this JsonNode? value, string propertyName) + => value is JsonObject obj && obj.ContainsKey(propertyName); + + [GenerateAssertion(ExpectationMessage = "to not have property \"{propertyName}\"", InlineMethodBody = true)] + public static bool DoesNotHaveJsonProperty(this JsonNode? value, string propertyName) + => value is not JsonObject obj || !obj.ContainsKey(propertyName); + +#if NET8_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be equal to {expected}", InlineMethodBody = true)] + public static bool IsEqualTo(this JsonNode? value, JsonNode? expected) + => JsonNode.DeepEquals(value, expected); + + [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}", InlineMethodBody = true)] + public static bool IsNotEqualTo(this JsonNode? value, JsonNode? expected) + => !JsonNode.DeepEquals(value, expected); +#endif +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" +``` + +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs TUnit.Assertions.Tests/JsonNodeAssertionTests.cs +git commit -m "feat(assertions): add JsonNode assertions with equality for .NET 8+" +``` + +--- + +## Task 6: Create JsonArray Assertions + +**Files:** +- Modify: `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` +- Modify: `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` + +**Step 1: Write the failing tests** + +Add to `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs`: + +```csharp + [Test] + public async Task IsEmpty_WithEmptyArray_Passes() + { + var array = new JsonArray(); + await Assert.That(array).IsJsonArrayEmpty(); + } + + [Test] + public async Task IsNotEmpty_WithNonEmptyArray_Passes() + { + var array = new JsonArray(1, 2, 3); + await Assert.That(array).IsJsonArrayNotEmpty(); + } + + [Test] + public async Task HasCount_WithMatchingCount_Passes() + { + var array = new JsonArray(1, 2, 3); + await Assert.That(array).HasJsonArrayCount(3); + } +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests.IsEmpty" --no-build +``` + +Expected: FAIL - methods not found + +**Step 3: Add JsonArray assertions** + +Add to `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs`: + +```csharp + [GenerateAssertion(ExpectationMessage = "to be an empty JSON array", InlineMethodBody = true)] + public static bool IsJsonArrayEmpty(this JsonArray value) + => value.Count == 0; + + [GenerateAssertion(ExpectationMessage = "to not be an empty JSON array", InlineMethodBody = true)] + public static bool IsJsonArrayNotEmpty(this JsonArray value) + => value.Count > 0; + + [GenerateAssertion(ExpectationMessage = "to have {expected} elements")] + public static TUnit.Assertions.Core.AssertionResult HasJsonArrayCount(this JsonArray value, int expected) + { + if (value.Count == expected) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"has {value.Count} elements"); + } +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" +``` + +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs TUnit.Assertions.Tests/JsonNodeAssertionTests.cs +git commit -m "feat(assertions): add JsonArray count and empty assertions" +``` + +--- + +## Task 7: Create String JSON Validation Assertions + +**Files:** +- Create: `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs` +- Create: `TUnit.Assertions.Tests/JsonStringAssertionTests.cs` + +**Step 1: Write the failing test** + +Create `TUnit.Assertions.Tests/JsonStringAssertionTests.cs`: + +```csharp +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonStringAssertionTests +{ + [Test] + public async Task IsValidJson_WithValidJson_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJson(); + } + + [Test] + public async Task IsValidJson_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJson()); + } + + [Test] + public async Task IsNotValidJson_WithInvalidJson_Passes() + { + var json = "not valid json"; + await Assert.That(json).IsNotValidJson(); + } + + [Test] + public async Task IsValidJsonObject_WithObject_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJsonObject(); + } + + [Test] + public async Task IsValidJsonObject_WithArray_Fails() + { + var json = "[1,2,3]"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonObject()); + } + + [Test] + public async Task IsValidJsonArray_WithArray_Passes() + { + var json = "[1,2,3]"; + await Assert.That(json).IsValidJsonArray(); + } + + [Test] + public async Task IsValidJsonArray_WithObject_Fails() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonArray()); + } +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonStringAssertionTests" --no-build +``` + +Expected: FAIL - methods not found + +**Step 3: Write the string JSON assertions** + +Create `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs`: + +```csharp +using System.Text.Json; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for validating JSON strings. +/// +file static partial class JsonStringAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be valid JSON")] + public static AssertionResult IsValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Passed; + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to not be valid JSON")] + public static AssertionResult IsNotValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Failed("is valid JSON"); + } + catch (JsonException) + { + return AssertionResult.Passed; + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON object")] + public static AssertionResult IsValidJsonObject(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Object"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON array")] + public static AssertionResult IsValidJsonArray(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Array"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonStringAssertionTests" +``` + +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs TUnit.Assertions.Tests/JsonStringAssertionTests.cs +git commit -m "feat(assertions): add JSON string validation assertions" +``` + +--- + +## Task 8: Run Full Test Suite and Update Snapshots + +**Files:** +- May modify: `TUnit.PublicAPI/**/*.verified.txt` +- May modify: `TUnit.Core.SourceGenerator.Tests/**/*.verified.txt` + +**Step 1: Run full assertion tests** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj +``` + +Expected: All tests pass + +**Step 2: Run public API tests** + +```bash +cd C:/git/TUnit && dotnet test TUnit.PublicAPI/TUnit.PublicAPI.csproj +``` + +Expected: May fail if new public APIs are detected + +**Step 3: Accept snapshots if needed** + +```bash +cd C:/git/TUnit/TUnit.PublicAPI +for %f in (*.received.txt) do move /Y "%f" "%~nf.verified.txt" +``` + +**Step 4: Run source generator tests** + +```bash +cd C:/git/TUnit && dotnet test TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj +``` + +Expected: Should pass (JSON assertions don't affect source generator) + +**Step 5: Commit snapshots** + +```bash +git add TUnit.PublicAPI/*.verified.txt +git commit -m "chore: update public API snapshots for JSON assertions" +``` + +--- + +## Task 9: Final Verification + +**Step 1: Run complete test suite** + +```bash +cd C:/git/TUnit && dotnet test +``` + +Expected: All tests pass + +**Step 2: Test AOT compatibility** + +```bash +cd C:/git/TUnit/TUnit.TestProject && dotnet publish -c Release -p:PublishAot=true --use-current-runtime +``` + +Expected: Publish succeeds + +**Step 3: Final commit if any cleanup needed** + +```bash +git status +# If clean, no action needed +# If changes, commit them +``` + +--- + +## Summary + +This plan creates 4 new files: +- `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs` - Path-to-difference logic +- `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` - JsonElement assertions +- `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` - JsonNode/JsonArray assertions +- `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs` - String validation assertions + +And 3 new test files: +- `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` +- `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` +- `TUnit.Assertions.Tests/JsonStringAssertionTests.cs` + +Total assertions: ~20 across all types with runtime-specific availability. From cdabd7d0ebc70904a0ce5170ee8bfa8be70202d7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 12:33:05 +0000 Subject: [PATCH 03/10] feat(assertions): add JsonElement property assertions --- .../JsonElementAssertionTests.cs | 30 +++++++++++++++++++ .../Json/JsonElementAssertionExtensions.cs | 8 +++++ 2 files changed, 38 insertions(+) diff --git a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs index 871873c112..99414a8272 100644 --- a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs +++ b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs @@ -109,4 +109,34 @@ public async Task IsNotNull_WithNull_Fails() await Assert.ThrowsAsync( async () => await Assert.That(doc.RootElement).IsNotNull()); } + + [Test] + public async Task HasProperty_WhenPropertyExists_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc.RootElement).HasProperty("name"); + } + + [Test] + public async Task HasProperty_WhenPropertyMissing_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).HasProperty("missing")); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc.RootElement).DoesNotHaveProperty("missing"); + } + + [Test] + public async Task DoesNotHaveProperty_WhenPropertyExists_Fails() + { + using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc.RootElement).DoesNotHaveProperty("name")); + } } diff --git a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs index 6b4ba1d018..37ce46cc11 100644 --- a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs +++ b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs @@ -35,4 +35,12 @@ public static bool IsNull(this JsonElement value) [GenerateAssertion(ExpectationMessage = "to not be JSON null", InlineMethodBody = true)] public static bool IsNotNull(this JsonElement value) => value.ValueKind != JsonValueKind.Null; + + [GenerateAssertion(ExpectationMessage = "to have property '{propertyName}'", InlineMethodBody = true)] + public static bool HasProperty(this JsonElement value, string propertyName) + => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(propertyName, out _); + + [GenerateAssertion(ExpectationMessage = "to not have property '{propertyName}'", InlineMethodBody = true)] + public static bool DoesNotHaveProperty(this JsonElement value, string propertyName) + => value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(propertyName, out _); } From fe355605f7cca7a600856b48c10c11cc3a97ed46 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:05:10 +0000 Subject: [PATCH 04/10] feat(assertions): add JsonElement deep equality assertions for .NET 9+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../JsonElementAssertionTests.cs | 47 +++++++++++++++++++ .../Json/JsonElementAssertionExtensions.cs | 21 ++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs index 99414a8272..d75275e8c7 100644 --- a/TUnit.Assertions.Tests/JsonElementAssertionTests.cs +++ b/TUnit.Assertions.Tests/JsonElementAssertionTests.cs @@ -139,4 +139,51 @@ public async Task DoesNotHaveProperty_WhenPropertyExists_Fails() await Assert.ThrowsAsync( async () => await Assert.That(doc.RootElement).DoesNotHaveProperty("name")); } + +#if NET9_0_OR_GREATER + [Test] + public async Task IsDeepEqualTo_WithIdenticalJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentWhitespace_Passes() + { + using var doc1 = JsonDocument.Parse("{ \"name\" : \"Alice\" }"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentValues_FailsWithPath() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":31}"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(doc1.RootElement).IsDeepEqualTo(doc2.RootElement)); + + await Assert.That(exception.Message).Contains("$.age"); + } + + [Test] + public async Task IsNotDeepEqualTo_WithDifferentJson_Passes() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Bob\"}"); + await Assert.That(doc1.RootElement).IsNotDeepEqualTo(doc2.RootElement); + } + + [Test] + public async Task IsNotDeepEqualTo_WithIdenticalJson_Fails() + { + using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(doc1.RootElement).IsNotDeepEqualTo(doc2.RootElement)); + } +#endif } diff --git a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs index 37ce46cc11..004591015f 100644 --- a/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs +++ b/TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs @@ -6,7 +6,7 @@ namespace TUnit.Assertions.Conditions.Json; /// /// Source-generated assertions for JsonElement type checking. /// -file static partial class JsonElementAssertionExtensions +public static partial class JsonElementAssertionExtensions { [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] public static bool IsObject(this JsonElement value) @@ -43,4 +43,23 @@ public static bool HasProperty(this JsonElement value, string propertyName) [GenerateAssertion(ExpectationMessage = "to not have property '{propertyName}'", InlineMethodBody = true)] public static bool DoesNotHaveProperty(this JsonElement value, string propertyName) => value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(propertyName, out _); + +#if NET9_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be deeply equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonElement value, JsonElement expected) + { + if (JsonElement.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return TUnit.Assertions.Core.AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); + } + + [GenerateAssertion(ExpectationMessage = "to not be deeply equal to {expected}", InlineMethodBody = true)] + public static bool IsNotDeepEqualTo(this JsonElement value, JsonElement expected) + => !JsonElement.DeepEquals(value, expected); +#endif } From 84b1a17626863b7b00e7e5b25b5c0be8163298be Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:14:10 +0000 Subject: [PATCH 05/10] feat(assertions): add JsonNode assertions with equality for .NET 8+ --- .../JsonNodeAssertionTests.cs | 118 ++++++++++++++++++ .../Json/JsonNodeAssertionExtensions.cs | 86 +++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 TUnit.Assertions.Tests/JsonNodeAssertionTests.cs create mode 100644 TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs diff --git a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs new file mode 100644 index 0000000000..1456a86edf --- /dev/null +++ b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Nodes; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonNodeAssertionTests +{ + [Test] + public async Task IsJsonObject_WithJsonObject_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.That(node).IsJsonObject(); + } + + [Test] + public async Task IsJsonObject_WithJsonArray_Fails() + { + JsonNode? node = JsonNode.Parse("[1,2,3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonObject()); + } + + [Test] + public async Task IsJsonArray_WithJsonArray_Passes() + { + JsonNode? node = JsonNode.Parse("[1,2,3]"); + await Assert.That(node).IsJsonArray(); + } + + [Test] + public async Task IsJsonArray_WithJsonObject_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArray()); + } + + [Test] + public async Task IsJsonValue_WithJsonValue_Passes() + { + JsonNode? node = JsonNode.Parse("42"); + await Assert.That(node).IsJsonValue(); + } + + [Test] + public async Task IsJsonValue_WithJsonObject_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonValue()); + } + + [Test] + public async Task HasJsonProperty_WhenPropertyExists_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).HasJsonProperty("name"); + } + + [Test] + public async Task HasJsonProperty_WhenPropertyMissing_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).HasJsonProperty("missing")); + } + + [Test] + public async Task DoesNotHaveJsonProperty_WhenPropertyMissing_Passes() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node).DoesNotHaveJsonProperty("missing"); + } + + [Test] + public async Task DoesNotHaveJsonProperty_WhenPropertyExists_Fails() + { + JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).DoesNotHaveJsonProperty("name")); + } + +#if NET8_0_OR_GREATER + [Test] + public async Task IsDeepEqualTo_WithIdenticalJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); + await Assert.That(node1).IsDeepEqualTo(node2); + } + + [Test] + public async Task IsDeepEqualTo_WithDifferentJson_Fails() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsDeepEqualTo(node2)); + } + + [Test] + public async Task IsNotDeepEqualTo_WithDifferentJson_Passes() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); + await Assert.That(node1).IsNotDeepEqualTo(node2); + } + + [Test] + public async Task IsNotDeepEqualTo_WithIdenticalJson_Fails() + { + JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsNotDeepEqualTo(node2)); + } +#endif +} diff --git a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs new file mode 100644 index 0000000000..e22110e692 --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Nodes; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for JsonNode types. +/// +public static partial class JsonNodeAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be a JsonObject")] + public static TUnit.Assertions.Core.AssertionResult IsJsonObject(this JsonNode? value) + { + if (value is JsonObject) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to be a JsonArray")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArray(this JsonNode? value) + { + if (value is JsonArray) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to be a JsonValue")] + public static TUnit.Assertions.Core.AssertionResult IsJsonValue(this JsonNode? value) + { + if (value is JsonValue) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"}"); + } + + [GenerateAssertion(ExpectationMessage = "to have property '{propertyName}'")] + public static TUnit.Assertions.Core.AssertionResult HasJsonProperty(this JsonNode? value, string propertyName) + { + if (value is JsonObject obj && obj.ContainsKey(propertyName)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + if (value is not JsonObject) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonObject"); + } + return TUnit.Assertions.Core.AssertionResult.Failed($"property '{propertyName}' not found"); + } + + [GenerateAssertion(ExpectationMessage = "to not have property '{propertyName}'")] + public static TUnit.Assertions.Core.AssertionResult DoesNotHaveJsonProperty(this JsonNode? value, string propertyName) + { + if (value is not JsonObject obj || !obj.ContainsKey(propertyName)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"property '{propertyName}' was found"); + } + +#if NET8_0_OR_GREATER + [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonNode? value, JsonNode? expected) + { + if (JsonNode.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value}"); + } + + [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}")] + public static TUnit.Assertions.Core.AssertionResult IsNotDeepEqualTo(this JsonNode? value, JsonNode? expected) + { + if (!JsonNode.DeepEquals(value, expected)) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"values are equal"); + } +#endif +} From 37a61651b743fa15374db28e81547e37f08c0cd7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:22:54 +0000 Subject: [PATCH 06/10] fix(assertions): add path-based error messages for JsonNode equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added JsonNode diff helper methods to JsonDiffHelper - Updated IsDeepEqualTo for JsonNode to show path-to-difference on failure - Added tests for whitespace handling and error message path verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../JsonNodeAssertionTests.cs | 20 +++ .../Conditions/Json/JsonDiffHelper.cs | 135 ++++++++++++++++++ .../Json/JsonNodeAssertionExtensions.cs | 7 +- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs index 1456a86edf..d76a3c8fea 100644 --- a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs +++ b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs @@ -89,6 +89,14 @@ public async Task IsDeepEqualTo_WithIdenticalJson_Passes() await Assert.That(node1).IsDeepEqualTo(node2); } + [Test] + public async Task IsDeepEqualTo_WithDifferentWhitespace_Passes() + { + JsonNode? node1 = JsonNode.Parse("{ \"name\" : \"Alice\" }"); + JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\"}"); + await Assert.That(node1).IsDeepEqualTo(node2); + } + [Test] public async Task IsDeepEqualTo_WithDifferentJson_Fails() { @@ -98,6 +106,18 @@ public async Task IsDeepEqualTo_WithDifferentJson_Fails() async () => await Assert.That(node1).IsDeepEqualTo(node2)); } + [Test] + public async Task IsDeepEqualTo_ErrorMessageContainsPath() + { + JsonNode? node1 = JsonNode.Parse("{\"person\":{\"name\":\"Alice\",\"age\":30}}"); + JsonNode? node2 = JsonNode.Parse("{\"person\":{\"name\":\"Alice\",\"age\":31}}"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(node1).IsDeepEqualTo(node2)); + + await Assert.That(exception.Message).Contains("$.person.age"); + } + [Test] public async Task IsNotDeepEqualTo_WithDifferentJson_Passes() { diff --git a/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs index 6816e7ffd9..31b0cf41c8 100644 --- a/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs +++ b/TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; namespace TUnit.Assertions.Conditions.Json; @@ -130,4 +131,138 @@ private static string FormatValue(JsonElement element) _ => element.GetRawText() }; } + + /// + /// Finds the first difference between two JSON nodes. + /// + /// The actual JSON node to compare. + /// The expected JSON node to compare against. + /// A containing information about the first difference found, + /// or a result with set to false if the nodes are identical. + public static DiffResult FindFirstDifference(JsonNode? actual, JsonNode? expected) + { + return FindNodeDiff(actual, expected, "$"); + } + + private static DiffResult FindNodeDiff(JsonNode? actual, JsonNode? expected, string path) + { + // Handle null cases + if (actual is null && expected is null) + { + return new DiffResult(path, "", "", HasDifference: false); + } + if (actual is null) + { + return new DiffResult(path, FormatNode(expected), "null"); + } + if (expected is null) + { + return new DiffResult(path, "null", FormatNode(actual)); + } + + // Check type mismatch + if (actual.GetType() != expected.GetType()) + { + return new DiffResult(path, GetNodeTypeName(expected), GetNodeTypeName(actual)); + } + + return actual switch + { + JsonObject actualObj => CompareJsonObjects(actualObj, (JsonObject)expected, path), + JsonArray actualArr => CompareJsonArrays(actualArr, (JsonArray)expected, path), + JsonValue actualVal => CompareJsonValues(actualVal, (JsonValue)expected, path), + _ => new DiffResult(path, "", "", HasDifference: false) + }; + } + + private static DiffResult CompareJsonObjects(JsonObject actual, JsonObject expected, string path) + { + // Check for missing properties in actual that exist in expected + foreach (var prop in expected) + { + var propPath = $"{path}.{prop.Key}"; + if (!actual.TryGetPropertyValue(prop.Key, out var actualProp)) + { + return new DiffResult(propPath, FormatNode(prop.Value), "(missing)"); + } + + var diff = FindNodeDiff(actualProp, prop.Value, propPath); + if (diff.HasDifference) + { + return diff; + } + } + + // Check for extra properties in actual that don't exist in expected + foreach (var prop in actual) + { + var propPath = $"{path}.{prop.Key}"; + if (!expected.ContainsKey(prop.Key)) + { + return new DiffResult(propPath, "(missing)", FormatNode(prop.Value)); + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareJsonArrays(JsonArray actual, JsonArray expected, string path) + { + if (actual.Count != expected.Count) + { + return new DiffResult($"{path}.Length", expected.Count.ToString(), actual.Count.ToString()); + } + + for (var i = 0; i < actual.Count; i++) + { + var itemPath = $"{path}[{i}]"; + var diff = FindNodeDiff(actual[i], expected[i], itemPath); + if (diff.HasDifference) + { + return diff; + } + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static DiffResult CompareJsonValues(JsonValue actual, JsonValue expected, string path) + { + var actualText = actual.ToJsonString(); + var expectedText = expected.ToJsonString(); + + if (actualText != expectedText) + { + return new DiffResult(path, expectedText, actualText); + } + + return new DiffResult(path, "", "", HasDifference: false); + } + + private static string GetNodeTypeName(JsonNode? node) + { + return node switch + { + JsonObject => "Object", + JsonArray => "Array", + JsonValue => "Value", + _ => "null" + }; + } + + private static string FormatNode(JsonNode? node) + { + if (node is null) + { + return "null"; + } + + return node switch + { + JsonObject => "{...}", + JsonArray => "[...]", + JsonValue val => val.ToJsonString(), + _ => node.ToJsonString() + }; + } } diff --git a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs index e22110e692..1a98798a7a 100644 --- a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs +++ b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs @@ -70,7 +70,10 @@ public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonNode? { return TUnit.Assertions.Core.AssertionResult.Passed; } - return TUnit.Assertions.Core.AssertionResult.Failed($"found {value}"); + + var diff = JsonDiffHelper.FindFirstDifference(value, expected); + return TUnit.Assertions.Core.AssertionResult.Failed( + $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); } [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}")] @@ -80,7 +83,7 @@ public static TUnit.Assertions.Core.AssertionResult IsNotDeepEqualTo(this JsonNo { return TUnit.Assertions.Core.AssertionResult.Passed; } - return TUnit.Assertions.Core.AssertionResult.Failed($"values are equal"); + return TUnit.Assertions.Core.AssertionResult.Failed("values are equal"); } #endif } From d6ef024df9cfd724e7118b14ff763922d0523aee Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:26:57 +0000 Subject: [PATCH 07/10] feat(assertions): add JsonArray count and empty assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added IsJsonArrayEmpty, IsJsonArrayNotEmpty, HasJsonArrayCount - Methods work on JsonNode? to avoid TUnit's collection assertion wrapping - Includes type checking for non-array inputs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../JsonNodeAssertionTests.cs | 46 +++++++++++++++++++ .../Json/JsonNodeAssertionExtensions.cs | 43 +++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs index d76a3c8fea..dafb019332 100644 --- a/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs +++ b/TUnit.Assertions.Tests/JsonNodeAssertionTests.cs @@ -80,6 +80,52 @@ public async Task DoesNotHaveJsonProperty_WhenPropertyExists_Fails() async () => await Assert.That(node).DoesNotHaveJsonProperty("name")); } + // JsonArray assertions - use JsonNode? to work with TUnit's assertion model + [Test] + public async Task IsJsonArrayEmpty_WithEmptyArray_Passes() + { + JsonNode? node = JsonNode.Parse("[]"); + await Assert.That(node).IsJsonArrayEmpty(); + } + + [Test] + public async Task IsJsonArrayEmpty_WithNonEmptyArray_Fails() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArrayEmpty()); + } + + [Test] + public async Task IsJsonArrayNotEmpty_WithNonEmptyArray_Passes() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.That(node).IsJsonArrayNotEmpty(); + } + + [Test] + public async Task IsJsonArrayNotEmpty_WithEmptyArray_Fails() + { + JsonNode? node = JsonNode.Parse("[]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).IsJsonArrayNotEmpty()); + } + + [Test] + public async Task HasJsonArrayCount_WithMatchingCount_Passes() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.That(node).HasJsonArrayCount(3); + } + + [Test] + public async Task HasJsonArrayCount_WithMismatchedCount_Fails() + { + JsonNode? node = JsonNode.Parse("[1, 2, 3]"); + await Assert.ThrowsAsync( + async () => await Assert.That(node).HasJsonArrayCount(5)); + } + #if NET8_0_OR_GREATER [Test] public async Task IsDeepEqualTo_WithIdenticalJson_Passes() diff --git a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs index 1a98798a7a..23e8aa8bb7 100644 --- a/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs +++ b/TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs @@ -62,6 +62,49 @@ public static TUnit.Assertions.Core.AssertionResult DoesNotHaveJsonProperty(this return TUnit.Assertions.Core.AssertionResult.Failed($"property '{propertyName}' was found"); } + // JsonArray assertions - work on JsonNode? to avoid collection assertion wrapping issues + [GenerateAssertion(ExpectationMessage = "to be an empty JSON array")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArrayEmpty(this JsonNode? value) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count == 0) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"has {array.Count} elements"); + } + + [GenerateAssertion(ExpectationMessage = "to not be an empty JSON array")] + public static TUnit.Assertions.Core.AssertionResult IsJsonArrayNotEmpty(this JsonNode? value) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count > 0) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed("array is empty"); + } + + [GenerateAssertion(ExpectationMessage = "to have {expected} elements")] + public static TUnit.Assertions.Core.AssertionResult HasJsonArrayCount(this JsonNode? value, int expected) + { + if (value is not JsonArray array) + { + return TUnit.Assertions.Core.AssertionResult.Failed($"found {value?.GetType().Name ?? "null"} instead of JsonArray"); + } + if (array.Count == expected) + { + return TUnit.Assertions.Core.AssertionResult.Passed; + } + return TUnit.Assertions.Core.AssertionResult.Failed($"has {array.Count} elements"); + } + #if NET8_0_OR_GREATER [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] public static TUnit.Assertions.Core.AssertionResult IsDeepEqualTo(this JsonNode? value, JsonNode? expected) From 6f2ae5b0ca289f077f566331bf012620cfe12c2e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:28:27 +0000 Subject: [PATCH 08/10] feat(assertions): add string JSON validation assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added IsValidJson, IsNotValidJson, IsValidJsonObject, IsValidJsonArray - Provides detailed error messages for parsing failures - Includes tests for valid, invalid, and type mismatch scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../JsonStringAssertionTests.cs | 82 +++++++++++++++++++ .../Json/JsonStringAssertionExtensions.cs | 75 +++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 TUnit.Assertions.Tests/JsonStringAssertionTests.cs create mode 100644 TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs diff --git a/TUnit.Assertions.Tests/JsonStringAssertionTests.cs b/TUnit.Assertions.Tests/JsonStringAssertionTests.cs new file mode 100644 index 0000000000..18b4eecfbf --- /dev/null +++ b/TUnit.Assertions.Tests/JsonStringAssertionTests.cs @@ -0,0 +1,82 @@ +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Tests; + +public class JsonStringAssertionTests +{ + [Test] + public async Task IsValidJson_WithValidJson_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJson(); + } + + [Test] + public async Task IsValidJson_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJson()); + } + + [Test] + public async Task IsNotValidJson_WithInvalidJson_Passes() + { + var json = "not valid json"; + await Assert.That(json).IsNotValidJson(); + } + + [Test] + public async Task IsNotValidJson_WithValidJson_Fails() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsNotValidJson()); + } + + [Test] + public async Task IsValidJsonObject_WithObject_Passes() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.That(json).IsValidJsonObject(); + } + + [Test] + public async Task IsValidJsonObject_WithArray_Fails() + { + var json = "[1,2,3]"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonObject()); + } + + [Test] + public async Task IsValidJsonObject_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonObject()); + } + + [Test] + public async Task IsValidJsonArray_WithArray_Passes() + { + var json = "[1,2,3]"; + await Assert.That(json).IsValidJsonArray(); + } + + [Test] + public async Task IsValidJsonArray_WithObject_Fails() + { + var json = "{\"name\":\"Alice\"}"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonArray()); + } + + [Test] + public async Task IsValidJsonArray_WithInvalidJson_Fails() + { + var json = "not valid json"; + await Assert.ThrowsAsync( + async () => await Assert.That(json).IsValidJsonArray()); + } +} diff --git a/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs b/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs new file mode 100644 index 0000000000..6d8a1fb25e --- /dev/null +++ b/TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Json; + +/// +/// Source-generated assertions for validating JSON strings. +/// +public static partial class JsonStringAssertionExtensions +{ + [GenerateAssertion(ExpectationMessage = "to be valid JSON")] + public static AssertionResult IsValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Passed; + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to not be valid JSON")] + public static AssertionResult IsNotValidJson(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + return AssertionResult.Failed("is valid JSON"); + } + catch (JsonException) + { + return AssertionResult.Passed; + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON object")] + public static AssertionResult IsValidJsonObject(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Object"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } + + [GenerateAssertion(ExpectationMessage = "to be a valid JSON array")] + public static AssertionResult IsValidJsonArray(this string value) + { + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + return AssertionResult.Passed; + } + return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Array"); + } + catch (JsonException ex) + { + return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); + } + } +} From 7336bf1f2158fad4dae5a624eb03e219a0cb32c4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:32:48 +0000 Subject: [PATCH 09/10] chore: update public API snapshots for JSON assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...Has_No_API_Changes.DotNet10_0.verified.txt | 246 ++++++++++++++++++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 228 ++++++++++++++++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 246 ++++++++++++++++++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 210 +++++++++++++++ 4 files changed, 930 insertions(+) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 193caf076a..1d4c70b02f 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1380,6 +1380,68 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to be deeply equal to {expected}")] + public static . IsDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be deeply equal to {expected}", InlineMethodBody=true)] + public static bool IsNotDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3138,6 +3200,166 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsDeepEqualTo_JsonElement_Assertion IsDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotDeepEqualTo_JsonElement_Assertion IsNotDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsNotDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3643,6 +3865,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index cdf708e41b..406d0957cf 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1375,6 +1375,64 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3119,6 +3177,152 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3623,6 +3827,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index de701714b4..30986ae454 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1380,6 +1380,68 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to be deeply equal to {expected}")] + public static . IsDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be deeply equal to {expected}", InlineMethodBody=true)] + public static bool IsNotDeepEqualTo(this . value, . expected) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be equal to {expected}")] + public static . IsDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be equal to {expected}")] + public static . IsNotDeepEqualTo(this ..JsonNode? value, ..JsonNode? expected) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -3138,6 +3200,166 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsDeepEqualTo_JsonElement_Assertion IsDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotDeepEqualTo_JsonElement_Assertion IsNotDeepEqualTo(this .<.> source, . expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotDeepEqualTo_JsonElement_Assertion : .<.> + { + public JsonElement_IsNotDeepEqualTo_JsonElement_Assertion(.<.> context, . expected) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsDeepEqualTo_JsonNode_Assertion IsDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + public static ._IsNotDeepEqualTo_JsonNode_Assertion IsNotDeepEqualTo(this .<..JsonNode?> source, ..JsonNode? expected, [.("expected")] string? expectedExpression = null) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsNotDeepEqualTo_JsonNode_Assertion : .<..JsonNode?> + { + public JsonNode_IsNotDeepEqualTo_JsonNode_Assertion(.<..JsonNode?> context, ..JsonNode? expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -3643,6 +3865,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] public sealed class T_IsIn_IEnumerableT_Assertion : . { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 702517902b..3d72a6033d 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1290,6 +1290,60 @@ namespace . } } namespace . +{ + public static class JsonElementAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool DoesNotHaveProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to have property \'{propertyName}\'", InlineMethodBody=true)] + public static bool HasProperty(this . value, string propertyName) { } + [.(ExpectationMessage="to be a JSON array", InlineMethodBody=true)] + public static bool IsArray(this . value) { } + [.(ExpectationMessage="to be a JSON boolean", InlineMethodBody=true)] + public static bool IsBoolean(this . value) { } + [.(ExpectationMessage="to not be JSON null", InlineMethodBody=true)] + public static bool IsNotNull(this . value) { } + [.(ExpectationMessage="to be JSON null", InlineMethodBody=true)] + public static bool IsNull(this . value) { } + [.(ExpectationMessage="to be a JSON number", InlineMethodBody=true)] + public static bool IsNumber(this . value) { } + [.(ExpectationMessage="to be a JSON object", InlineMethodBody=true)] + public static bool IsObject(this . value) { } + [.(ExpectationMessage="to be a JSON string", InlineMethodBody=true)] + public static bool IsString(this . value) { } + } + public static class JsonNodeAssertionExtensions + { + [.(ExpectationMessage="to not have property \'{propertyName}\'")] + public static . DoesNotHaveJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to have {expected} elements")] + public static . HasJsonArrayCount(this ..JsonNode? value, int expected) { } + [.(ExpectationMessage="to have property \'{propertyName}\'")] + public static . HasJsonProperty(this ..JsonNode? value, string propertyName) { } + [.(ExpectationMessage="to be a JsonArray")] + public static . IsJsonArray(this ..JsonNode? value) { } + [.(ExpectationMessage="to be an empty JSON array")] + public static . IsJsonArrayEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to not be an empty JSON array")] + public static . IsJsonArrayNotEmpty(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonObject")] + public static . IsJsonObject(this ..JsonNode? value) { } + [.(ExpectationMessage="to be a JsonValue")] + public static . IsJsonValue(this ..JsonNode? value) { } + } + public static class JsonStringAssertionExtensions + { + [.(ExpectationMessage="to not be valid JSON")] + public static . IsNotValidJson(this string value) { } + [.(ExpectationMessage="to be valid JSON")] + public static . IsValidJson(this string value) { } + [.(ExpectationMessage="to be a valid JSON array")] + public static . IsValidJsonArray(this string value) { } + [.(ExpectationMessage="to be a valid JSON object")] + public static . IsValidJsonObject(this string value) { } + } +} +namespace . { public class CountWrapper : ., . where TCollection : . @@ -2846,6 +2900,138 @@ namespace .Extensions public static . IsNotDefault(this . source) where TValue : class { } } + public static class JsonElementAssertionExtensions + { + public static ._DoesNotHaveProperty_String_Assertion DoesNotHaveProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasProperty_String_Assertion HasProperty(this .<.> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsArray_Assertion IsArray(this .<.> source) { } + public static ._IsBoolean_Assertion IsBoolean(this .<.> source) { } + public static ._IsNotNull_Assertion IsNotNull(this .<.> source) { } + public static ._IsNull_Assertion IsNull(this .<.> source) { } + public static ._IsNumber_Assertion IsNumber(this .<.> source) { } + public static ._IsObject_Assertion IsObject(this .<.> source) { } + public static ._IsString_Assertion IsString(this .<.> source) { } + } + public sealed class JsonElement_DoesNotHaveProperty_String_Assertion : .<.> + { + public JsonElement_DoesNotHaveProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_HasProperty_String_Assertion : .<.> + { + public JsonElement_HasProperty_String_Assertion(.<.> context, string propertyName) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsArray_Assertion : .<.> + { + public JsonElement_IsArray_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsBoolean_Assertion : .<.> + { + public JsonElement_IsBoolean_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNotNull_Assertion : .<.> + { + public JsonElement_IsNotNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNull_Assertion : .<.> + { + public JsonElement_IsNull_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsNumber_Assertion : .<.> + { + public JsonElement_IsNumber_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsObject_Assertion : .<.> + { + public JsonElement_IsObject_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonElement_IsString_Assertion : .<.> + { + public JsonElement_IsString_Assertion(.<.> context) { } + protected override .<.> CheckAsync(.<.> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonNodeAssertionExtensions + { + public static ._DoesNotHaveJsonProperty_String_Assertion DoesNotHaveJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._HasJsonArrayCount_Int_Assertion HasJsonArrayCount(this .<..JsonNode?> source, int expected, [.("expected")] string? expectedExpression = null) { } + public static ._HasJsonProperty_String_Assertion HasJsonProperty(this .<..JsonNode?> source, string propertyName, [.("propertyName")] string? propertyNameExpression = null) { } + public static ._IsJsonArray_Assertion IsJsonArray(this .<..JsonNode?> source) { } + public static ._IsJsonArrayEmpty_Assertion IsJsonArrayEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonArrayNotEmpty_Assertion IsJsonArrayNotEmpty(this .<..JsonNode?> source) { } + public static ._IsJsonObject_Assertion IsJsonObject(this .<..JsonNode?> source) { } + public static ._IsJsonValue_Assertion IsJsonValue(this .<..JsonNode?> source) { } + } + public sealed class JsonNode_DoesNotHaveJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_DoesNotHaveJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonArrayCount_Int_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonArrayCount_Int_Assertion(.<..JsonNode?> context, int expected) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_HasJsonProperty_String_Assertion : .<..JsonNode?> + { + public JsonNode_HasJsonProperty_String_Assertion(.<..JsonNode?> context, string propertyName) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArrayNotEmpty_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArrayNotEmpty_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonArray_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonArray_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonObject_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonObject_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public sealed class JsonNode_IsJsonValue_Assertion : .<..JsonNode?> + { + public JsonNode_IsJsonValue_Assertion(.<..JsonNode?> context) { } + protected override .<.> CheckAsync(.<..JsonNode?> metadata) { } + protected override string GetExpectation() { } + } + public static class JsonStringAssertionExtensions + { + public static ._IsNotValidJson_Assertion IsNotValidJson(this . source) { } + public static ._IsValidJson_Assertion IsValidJson(this . source) { } + public static ._IsValidJsonArray_Assertion IsValidJsonArray(this . source) { } + public static ._IsValidJsonObject_Assertion IsValidJsonObject(this . source) { } + } public static class LazyAssertionExtensions { public static ._IsValueCreated_Assertion IsValueCreated(this .<> source) { } @@ -3197,6 +3383,30 @@ namespace .Extensions public static . IsNullOrEmpty(this . source) { } public static . IsNullOrWhiteSpace(this . source) { } } + public sealed class String_IsNotValidJson_Assertion : . + { + public String_IsNotValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonArray_Assertion : . + { + public String_IsValidJsonArray_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJsonObject_Assertion : . + { + public String_IsValidJsonObject_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public sealed class String_IsValidJson_Assertion : . + { + public String_IsValidJson_Assertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public sealed class T_IsIn_IEnumerableT_Assertion : . { public T_IsIn_IEnumerableT_Assertion(. context, . collection) { } From 664a85696892c89b7b3e00b4efa049b89719cae0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:40:09 +0000 Subject: [PATCH 10/10] feat(assertions): remove JSON assertions design and implementation plan documents - Deleted the JSON Assertions Design document and the JSON Assertions Implementation Plan document as they are no longer needed. --- ...-25-nunit-expectedresult-implementation.md | 800 -------------- ...5-nunit-expectedresult-migration-design.md | 215 ---- ...-26-lock-contention-optimization-design.md | 377 ------- ...2025-12-26-lock-contention-optimization.md | 505 --------- .../2025-12-28-json-assertions-design.md | 230 ---- docs/plans/2025-12-28-json-assertions.md | 991 ------------------ 6 files changed, 3118 deletions(-) delete mode 100644 docs/plans/2025-12-25-nunit-expectedresult-implementation.md delete mode 100644 docs/plans/2025-12-25-nunit-expectedresult-migration-design.md delete mode 100644 docs/plans/2025-12-26-lock-contention-optimization-design.md delete mode 100644 docs/plans/2025-12-26-lock-contention-optimization.md delete mode 100644 docs/plans/2025-12-28-json-assertions-design.md delete mode 100644 docs/plans/2025-12-28-json-assertions.md diff --git a/docs/plans/2025-12-25-nunit-expectedresult-implementation.md b/docs/plans/2025-12-25-nunit-expectedresult-implementation.md deleted file mode 100644 index c27e59817e..0000000000 --- a/docs/plans/2025-12-25-nunit-expectedresult-implementation.md +++ /dev/null @@ -1,800 +0,0 @@ -# NUnit ExpectedResult Migration Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add code fixer support for migrating NUnit's `ExpectedResult` pattern to TUnit's assertion-based approach. - -**Architecture:** Extend the existing `NUnitMigrationCodeFixProvider` with a new `NUnitExpectedResultRewriter` that transforms `[TestCase(..., ExpectedResult = X)]` into `[Arguments(..., X)]` with an assertion in the method body. The rewriter runs in the `ApplyFrameworkSpecificConversions` hook before attribute conversion. - -**Tech Stack:** Roslyn CodeAnalysis, C# Syntax Rewriters, existing TUnit.Analyzers infrastructure. - ---- - -## Task 1: Add Failing Test for Simple ExpectedResult - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the failing test** - -Add this test method at the end of the test class (before the `ConfigureNUnitTest` methods): - -```csharp -[Test] -public async Task NUnit_ExpectedResult_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - public int Add(int a, int b) => a + b; - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - public async Task Add(int a, int b, int expected) - { - await Assert.That(a + b).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: FAIL (code fixer doesn't handle ExpectedResult yet) - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add failing test for NUnit ExpectedResult migration" -``` - ---- - -## Task 2: Create NUnitExpectedResultRewriter Class - -**Files:** -- Create: `TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs` - -**Step 1: Create the rewriter skeleton** - -```csharp -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace TUnit.Analyzers.CodeFixers; - -/// -/// Transforms NUnit [TestCase(..., ExpectedResult = X)] patterns to TUnit assertions. -/// -public class NUnitExpectedResultRewriter : CSharpSyntaxRewriter -{ - private readonly SemanticModel _semanticModel; - - public NUnitExpectedResultRewriter(SemanticModel semanticModel) - { - _semanticModel = semanticModel; - } - - public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) - { - // Check if method has any TestCase attributes with ExpectedResult - var testCaseAttributes = GetTestCaseAttributesWithExpectedResult(node); - - if (testCaseAttributes.Count == 0) - { - return base.VisitMethodDeclaration(node); - } - - // Get the return type (will become the expected parameter type) - var returnType = node.ReturnType; - if (returnType is PredefinedTypeSyntax predefined && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword)) - { - // void methods can't have ExpectedResult - return base.VisitMethodDeclaration(node); - } - - // Transform the method - return TransformMethod(node, testCaseAttributes, returnType); - } - - private List GetTestCaseAttributesWithExpectedResult(MethodDeclarationSyntax method) - { - var result = new List(); - - foreach (var attributeList in method.AttributeLists) - { - foreach (var attribute in attributeList.Attributes) - { - var name = attribute.Name.ToString(); - if (name is "TestCase" or "NUnit.Framework.TestCase" or "TestCaseAttribute" or "NUnit.Framework.TestCaseAttribute") - { - if (HasExpectedResultArgument(attribute)) - { - result.Add(attribute); - } - } - } - } - - return result; - } - - private bool HasExpectedResultArgument(AttributeSyntax attribute) - { - if (attribute.ArgumentList == null) - { - return false; - } - - return attribute.ArgumentList.Arguments - .Any(arg => arg.NameEquals?.Name.Identifier.Text == "ExpectedResult"); - } - - private MethodDeclarationSyntax TransformMethod( - MethodDeclarationSyntax method, - List testCaseAttributes, - TypeSyntax originalReturnType) - { - // 1. Add 'expected' parameter - var expectedParam = SyntaxFactory.Parameter(SyntaxFactory.Identifier("expected")) - .WithType(originalReturnType.WithTrailingTrivia(SyntaxFactory.Space)); - - var newParameters = method.ParameterList.AddParameters(expectedParam); - - // 2. Change return type to async Task - var asyncTaskType = SyntaxFactory.ParseTypeName("async Task ") - .WithTrailingTrivia(SyntaxFactory.Space); - - // 3. Transform the body - var newBody = TransformBody(method, originalReturnType); - - // 4. Build the new method - var newMethod = method - .WithReturnType(SyntaxFactory.ParseTypeName("Task").WithTrailingTrivia(SyntaxFactory.Space)) - .WithParameterList(newParameters) - .WithBody(newBody) - .WithExpressionBody(null) - .WithSemicolonToken(default); - - // 5. Add async modifier if not present - if (!method.Modifiers.Any(SyntaxKind.AsyncKeyword)) - { - var asyncModifier = SyntaxFactory.Token(SyntaxKind.AsyncKeyword).WithTrailingTrivia(SyntaxFactory.Space); - newMethod = newMethod.AddModifiers(asyncModifier); - } - - // 6. Update attribute lists (remove ExpectedResult from TestCase, add [Test]) - var newAttributeLists = TransformAttributeLists(method.AttributeLists, testCaseAttributes); - newMethod = newMethod.WithAttributeLists(newAttributeLists); - - return newMethod; - } - - private BlockSyntax TransformBody(MethodDeclarationSyntax method, TypeSyntax returnType) - { - ExpressionSyntax returnExpression; - - if (method.ExpressionBody != null) - { - // Expression-bodied: => a + b - returnExpression = method.ExpressionBody.Expression; - } - else if (method.Body != null) - { - // Block body - find return statements - var returnStatements = method.Body.Statements - .OfType() - .ToList(); - - if (returnStatements.Count == 1 && returnStatements[0].Expression != null) - { - // Single return - use the expression directly - returnExpression = returnStatements[0].Expression; - - // Build new body with all statements except the return, plus assertion - var statementsWithoutReturn = method.Body.Statements - .Where(s => s != returnStatements[0]) - .ToList(); - - var assertStatement = CreateAssertStatement(returnExpression); - statementsWithoutReturn.Add(assertStatement); - - return SyntaxFactory.Block(statementsWithoutReturn); - } - else if (returnStatements.Count > 1) - { - // Multiple returns - use local variable pattern - return TransformMultipleReturns(method.Body, returnType); - } - else - { - // No return found - shouldn't happen for ExpectedResult - return method.Body; - } - } - else - { - // No body - shouldn't happen - return SyntaxFactory.Block(); - } - - // For expression-bodied, create block with assertion - var assertion = CreateAssertStatement(returnExpression); - return SyntaxFactory.Block(assertion); - } - - private BlockSyntax TransformMultipleReturns(BlockSyntax body, TypeSyntax returnType) - { - // Declare: {returnType} result; - var resultDeclaration = SyntaxFactory.LocalDeclarationStatement( - SyntaxFactory.VariableDeclaration(returnType.WithTrailingTrivia(SyntaxFactory.Space)) - .WithVariables(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.VariableDeclarator("result")))); - - // Replace each 'return X;' with 'result = X;' - var rewriter = new ReturnToAssignmentRewriter(); - var transformedBody = (BlockSyntax)rewriter.Visit(body); - - // Add result declaration at start - var statements = new List { resultDeclaration }; - statements.AddRange(transformedBody.Statements); - - // Add assertion at end - var assertStatement = CreateAssertStatement( - SyntaxFactory.IdentifierName("result")); - statements.Add(assertStatement); - - return SyntaxFactory.Block(statements); - } - - private ExpressionStatementSyntax CreateAssertStatement(ExpressionSyntax actualExpression) - { - // await Assert.That(actualExpression).IsEqualTo(expected); - var assertThat = SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("Assert"), - SyntaxFactory.IdentifierName("That")), - SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(actualExpression)))); - - var isEqualTo = SyntaxFactory.InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - assertThat, - SyntaxFactory.IdentifierName("IsEqualTo")), - SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("expected"))))); - - var awaitExpr = SyntaxFactory.AwaitExpression(isEqualTo); - - return SyntaxFactory.ExpressionStatement(awaitExpr); - } - - private SyntaxList TransformAttributeLists( - SyntaxList attributeLists, - List testCaseAttributes) - { - var result = new List(); - bool hasTestAttribute = false; - - foreach (var attrList in attributeLists) - { - var newAttributes = new List(); - - foreach (var attr in attrList.Attributes) - { - var name = attr.Name.ToString(); - - if (name is "Test" or "NUnit.Framework.Test") - { - hasTestAttribute = true; - newAttributes.Add(attr); - } - else if (testCaseAttributes.Contains(attr)) - { - // Transform TestCase with ExpectedResult to Arguments - var transformed = TransformTestCaseAttribute(attr); - newAttributes.Add(transformed); - } - else - { - newAttributes.Add(attr); - } - } - - if (newAttributes.Count > 0) - { - result.Add(attrList.WithAttributes(SyntaxFactory.SeparatedList(newAttributes))); - } - } - - // Add [Test] attribute if not present - if (!hasTestAttribute) - { - var testAttr = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("Test")); - var testAttrList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(testAttr)) - .WithLeadingTrivia(attributeLists.First().GetLeadingTrivia()); - result.Insert(0, testAttrList); - } - - return SyntaxFactory.List(result); - } - - private AttributeSyntax TransformTestCaseAttribute(AttributeSyntax attribute) - { - if (attribute.ArgumentList == null) - { - return attribute; - } - - var newArgs = new List(); - ExpressionSyntax? expectedValue = null; - - foreach (var arg in attribute.ArgumentList.Arguments) - { - if (arg.NameEquals?.Name.Identifier.Text == "ExpectedResult") - { - expectedValue = arg.Expression; - } - else if (arg.NameColon == null && arg.NameEquals == null) - { - // Positional argument - keep it - newArgs.Add(arg); - } - // Skip other named arguments for now - } - - // Add expected value as last positional argument - if (expectedValue != null) - { - newArgs.Add(SyntaxFactory.AttributeArgument(expectedValue)); - } - - // The attribute will be renamed to "Arguments" by the existing attribute rewriter - return attribute.WithArgumentList( - SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(newArgs))); - } - - private class ReturnToAssignmentRewriter : CSharpSyntaxRewriter - { - public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) - { - if (node.Expression == null) - { - return node; - } - - // return X; -> result = X; - return SyntaxFactory.ExpressionStatement( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("result"), - node.Expression)); - } - } -} -``` - -**Step 2: Build to verify no syntax errors** - -Run: `dotnet build TUnit.Analyzers.CodeFixers` - -Expected: Build succeeded - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs -git commit -m "feat: add NUnitExpectedResultRewriter skeleton" -``` - ---- - -## Task 3: Integrate Rewriter into Code Fix Provider - -**Files:** -- Modify: `TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs` - -**Step 1: Update ApplyFrameworkSpecificConversions** - -Replace the `ApplyFrameworkSpecificConversions` method: - -```csharp -protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation) -{ - // Transform ExpectedResult patterns before attribute conversion - var expectedResultRewriter = new NUnitExpectedResultRewriter(semanticModel); - compilationUnit = (CompilationUnitSyntax)expectedResultRewriter.Visit(compilationUnit); - - return compilationUnit; -} -``` - -**Step 2: Run the test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: May pass or fail depending on edge cases - we'll iterate - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs -git commit -m "feat: integrate ExpectedResult rewriter into NUnit migration" -``` - ---- - -## Task 4: Fix Formatting Issues - -**Files:** -- Modify: `TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs` - -**Step 1: Run test and check output** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted" -v n` - -Examine the actual output vs expected - likely issues with: -- Trivia (whitespace, newlines) -- Attribute ordering -- Method modifier ordering - -**Step 2: Fix identified issues** - -Common fixes needed: -- Add proper leading/trailing trivia to statements -- Ensure async modifier is in correct position -- Fix attribute list formatting - -**Step 3: Run test again** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_Converted"` - -Expected: PASS - -**Step 4: Commit** - -```bash -git add TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs -git commit -m "fix: correct formatting in ExpectedResult transformation" -``` - ---- - -## Task 5: Add Test for Multiple TestCase Attributes - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_Multiple_ExpectedResult_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - [TestCase(10, 5, ExpectedResult = 15)] - [TestCase(0, 0, ExpectedResult = 0)] - public int Add(int a, int b) => a + b; - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - [Arguments(10, 5, 15)] - [Arguments(0, 0, 0)] - public async Task Add(int a, int b, int expected) - { - await Assert.That(a + b).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_Multiple_ExpectedResult_Converted"` - -Expected: PASS - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for multiple TestCase with ExpectedResult" -``` - ---- - -## Task 6: Add Test for Block-Bodied Method with Single Return - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_BlockBody_SingleReturn_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(2, 3, ExpectedResult = 5)] - public int Add(int a, int b) - { - var sum = a + b; - return sum; - } - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(2, 3, 5)] - public async Task Add(int a, int b, int expected) - { - var sum = a + b; - await Assert.That(sum).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_BlockBody_SingleReturn_Converted"` - -Expected: PASS (or fix if needed) - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for block-bodied ExpectedResult with single return" -``` - ---- - -## Task 7: Add Test for Multiple Return Statements - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_MultipleReturns_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase(-1, ExpectedResult = 0)] - [TestCase(0, ExpectedResult = 1)] - [TestCase(5, ExpectedResult = 120)] - public int Factorial(int n) - { - if (n < 0) return 0; - if (n <= 1) return 1; - return n * Factorial(n - 1); - } - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments(-1, 0)] - [Arguments(0, 1)] - [Arguments(5, 120)] - public async Task Factorial(int n, int expected) - { - int result; - if (n < 0) result = 0; - else if (n <= 1) result = 1; - else result = n * Factorial(n - 1); - await Assert.That(result).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_MultipleReturns_Converted"` - -Expected: May fail initially - multiple returns require more complex transformation - -**Step 3: Fix if needed and commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for ExpectedResult with multiple returns" -``` - ---- - -## Task 8: Add Test for String ExpectedResult - -**Files:** -- Modify: `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs` - -**Step 1: Write the test** - -```csharp -[Test] -public async Task NUnit_ExpectedResult_String_Converted() -{ - await CodeFixer.VerifyCodeFixAsync( - """ - using NUnit.Framework; - - {|#0:public class MyClass|} - { - [TestCase("hello", ExpectedResult = "HELLO")] - [TestCase("World", ExpectedResult = "WORLD")] - public string ToUpper(string input) => input.ToUpper(); - } - """, - Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), - """ - using TUnit.Core; - using TUnit.Assertions; - using static TUnit.Assertions.Assert; - using TUnit.Assertions.Extensions; - - public class MyClass - { - [Test] - [Arguments("hello", "HELLO")] - [Arguments("World", "WORLD")] - public async Task ToUpper(string input, string expected) - { - await Assert.That(input.ToUpper()).IsEqualTo(expected); - } - } - """, - ConfigureNUnitTest - ); -} -``` - -**Step 2: Run test** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnit_ExpectedResult_String_Converted"` - -Expected: PASS - -**Step 3: Commit** - -```bash -git add TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs -git commit -m "test: add test for string ExpectedResult migration" -``` - ---- - -## Task 9: Run Full Test Suite - -**Files:** None (verification only) - -**Step 1: Run all NUnit migration tests** - -Run: `dotnet test TUnit.Analyzers.Tests --filter "NUnitMigration"` - -Expected: All tests PASS - -**Step 2: Run full analyzer test suite** - -Run: `dotnet test TUnit.Analyzers.Tests` - -Expected: All tests PASS - -**Step 3: Commit any remaining fixes** - -```bash -git add -A -git commit -m "fix: address any remaining test failures" -``` - ---- - -## Task 10: Update Design Document Status - -**Files:** -- Modify: `docs/plans/2025-12-25-nunit-expectedresult-migration-design.md` - -**Step 1: Update status** - -Change the Status line from: -``` -**Status**: Design Complete -``` - -To: -``` -**Status**: Implemented -``` - -**Step 2: Commit** - -```bash -git add docs/plans/2025-12-25-nunit-expectedresult-migration-design.md -git commit -m "docs: mark ExpectedResult migration as implemented" -``` - ---- - -## Verification Checklist - -After all tasks complete: - -- [ ] `dotnet test TUnit.Analyzers.Tests` passes -- [ ] `dotnet build TUnit.Analyzers.CodeFixers` succeeds -- [ ] New tests cover: simple ExpectedResult, multiple TestCase, block body, multiple returns, string types -- [ ] Code follows existing patterns in NUnitMigrationCodeFixProvider diff --git a/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md b/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md deleted file mode 100644 index 6fc8da8aeb..0000000000 --- a/docs/plans/2025-12-25-nunit-expectedresult-migration-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# NUnit ExpectedResult Migration Code Fixer - -**Issue**: #4167 -**Date**: 2025-12-25 -**Status**: Implemented - -## Overview - -A Roslyn analyzer + code fixer that extends TUnit's existing NUnit migration infrastructure to handle `ExpectedResult` patterns. Converts NUnit's return-value-based test assertions to TUnit's explicit assertion approach. - -## Scope - -### Patterns Handled - -1. `[TestCase(..., ExpectedResult = X)]` - Inline expected result property -2. `TestCaseData.Returns(X)` - Fluent expected result in data sources - -### Patterns Flagged for Manual Review - -- Mixed attributes (some with ExpectedResult, some without) -- `TestCaseData` with `.SetName()`, `.SetCategory()`, or other chained methods -- Dynamic/computed `TestCaseData` (loops, conditionals) -- `TestCaseData` constructed outside simple array/collection initializers - -## Transformation Examples - -### TestCase with ExpectedResult - -```csharp -// BEFORE (NUnit) -[TestCase(2, 3, ExpectedResult = 5)] -[TestCase(10, 5, ExpectedResult = 15)] -public int Add(int a, int b) => a + b; - -// AFTER (TUnit) -[Test] -[Arguments(2, 3, 5)] -[Arguments(10, 5, 15)] -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -### TestCaseData.Returns() - -```csharp -// BEFORE (NUnit) -public static IEnumerable AddCases => new[] -{ - new TestCaseData(2, 3).Returns(5), - new TestCaseData(10, 5).Returns(15) -}; - -[TestCaseSource(nameof(AddCases))] -public int Add(int a, int b) => a + b; - -// AFTER (TUnit) -public static IEnumerable<(int, int, int)> AddCases => new[] -{ - (2, 3, 5), - (10, 5, 15) -}; - -[Test] -[MethodDataSource(nameof(AddCases))] -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Multiple ExpectedResults | Add as method parameter | Preserves parameterized structure, single method | -| Expression-bodied members | Convert to block body | Semantic change warrants explicit block | -| Multiple return statements | Extract to local variable | Cleaner code, single assertion point | -| Null expected values | Use `IsEqualTo(expected)` | Parameter value unknown at compile-time | -| Parameter naming | `expected` | Concise, idiomatic in testing | - -## Method Body Transformation - -### Case A: Expression-Bodied - -```csharp -// BEFORE -public int Add(int a, int b) => a + b; - -// AFTER -public async Task Add(int a, int b, int expected) -{ - await Assert.That(a + b).IsEqualTo(expected); -} -``` - -### Case B: Single Return - -```csharp -// BEFORE -public int Add(int a, int b) -{ - var sum = a + b; - return sum; -} - -// AFTER -public async Task Add(int a, int b, int expected) -{ - var sum = a + b; - await Assert.That(sum).IsEqualTo(expected); -} -``` - -### Case C: Multiple Returns - -```csharp -// BEFORE -public int Factorial(int n) -{ - if (n < 0) return 0; - if (n <= 1) return 1; - return n * Factorial(n - 1); -} - -// AFTER -public async Task Factorial(int n, int expected) -{ - int result; - if (n < 0) result = 0; - else if (n <= 1) result = 1; - else result = n * Factorial(n - 1); - await Assert.That(result).IsEqualTo(expected); -} -``` - -**Algorithm for multiple returns**: -1. Declare `{returnType} result;` at method start -2. Replace each `return X;` with `result = X;` -3. Convert `if (...) return` chains to `if/else if/else` -4. Append `await Assert.That(result).IsEqualTo(expected);` at end - -## Implementation - -### Analyzer - -Extend `NUnitMigrationAnalyzer` to detect: -- `ExpectedResult` named argument in `[TestCase]` attributes -- `.Returns()` method calls on `TestCaseData` - -**Diagnostic**: `TUnit0050` (Info severity) -**Message**: "NUnit ExpectedResult can be converted to TUnit assertion" - -### Code Fixer - -#### New Files - -``` -TUnit.Analyzers.CodeFixers/ -├── NUnitExpectedResultRewriter.cs # TestCase ExpectedResult transform -└── NUnitTestCaseDataRewriter.cs # TestCaseData.Returns() transform -``` - -#### Integration - -Add to `NUnitMigrationCodeFixProvider.cs` transformation pipeline: - -```csharp -// Before attribute conversion: -root = new NUnitExpectedResultRewriter(semanticModel).Visit(root); -root = new NUnitTestCaseDataRewriter(semanticModel).Visit(root); -``` - -### TestCase Transformation Steps - -1. Extract `ExpectedResult` values from each `[TestCase]` -2. Convert `[TestCase(args, ExpectedResult = X)]` → `[Arguments(args, X)]` -3. Add `expected` parameter with original return type -4. Change return type to `async Task` -5. Transform method body per cases above -6. Add `[Test]` attribute if not present - -### TestCaseData Transformation Steps - -1. Locate data source method/property -2. For each `TestCaseData`: extract args + `.Returns()` value → tuple -3. Update return type: `IEnumerable` → `IEnumerable<(T1, T2, ..., TExpected)>` -4. Transform test method same as TestCase - -## Testing - -Add to `TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs`: - -- Simple ExpectedResult (single TestCase) -- Multiple TestCase attributes with different expected values -- Expression-bodied methods -- Block-bodied with single return -- Block-bodied with multiple returns -- Null expected values -- Reference type expected values -- TestCaseData.Returns() simple case -- TestCaseData.Returns() with multiple entries -- Mixed scenarios (flagged for manual review) - -## Edge Cases - -| Scenario | Handling | -|----------|----------| -| Null ExpectedResult | `IsEqualTo(expected)` handles null at runtime | -| Constant references (`int.MaxValue`) | Passed through as parameter value | -| Generic return types | Parameter type matches original return type | -| Nullable return types | Parameter type includes nullability | -| Recursive methods | Works - method signature change is compatible | -| Mixed TestCase (with/without ExpectedResult) | Flag for manual review | diff --git a/docs/plans/2025-12-26-lock-contention-optimization-design.md b/docs/plans/2025-12-26-lock-contention-optimization-design.md deleted file mode 100644 index 010cb0313f..0000000000 --- a/docs/plans/2025-12-26-lock-contention-optimization-design.md +++ /dev/null @@ -1,377 +0,0 @@ -# Lock Contention Optimization Design - -**Issue:** [#4162 - perf: reduce lock contention in test discovery and scheduling](https://github.com/thomhurst/TUnit/issues/4162) -**Date:** 2025-12-26 -**Priority:** P1 -**Goal:** Maximum parallelism - minimize lock duration at any cost - ---- - -## Problem Statement - -Two performance-critical code paths unnecessarily extend lock durations, creating bottlenecks in parallel test execution: - -1. **ReflectionTestDataCollector.cs (lines 137-141):** Lock remains held while creating a full defensive copy of discovered tests -2. **ConstraintKeyScheduler.cs (lines 56-69, 151-190):** LINQ evaluation and list allocation happen within lock scope - -These practices serialize operations that should execute in parallel, degrading throughput on multi-core systems. - ---- - -## Solution Overview - -| Component | Approach | Complexity | -|-----------|----------|------------| -| ReflectionTestDataCollector | ImmutableList with atomic swap | Medium | -| ConstraintKeyScheduler | Manual loops + two-phase locking + pre-allocation | High | - ---- - -## Design: ReflectionTestDataCollector - -### Current Implementation (Problem) - -```csharp -private static readonly List _discoveredTests = new(capacity: 1000); -private static readonly Lock _discoveredTestsLock = new(); - -public async Task> CollectTestsAsync(string testSessionId) -{ - // ... discovery logic ... - - lock (_discoveredTestsLock) - { - _discoveredTests.AddRange(newTests); - return new List(_discoveredTests); // O(n) copy under lock - } -} -``` - -### Proposed Implementation - -Replace `List` + lock with `ImmutableList` + atomic swap: - -```csharp -private static ImmutableList _discoveredTests = ImmutableList.Empty; - -public async Task> CollectTestsAsync(string testSessionId) -{ - // ... discovery logic unchanged ... - - // Atomic swap - no lock needed for readers - ImmutableList original, updated; - do - { - original = _discoveredTests; - updated = original.AddRange(newTests); - } while (Interlocked.CompareExchange(ref _discoveredTests, updated, original) != original); - - return _discoveredTests; // Already immutable, no copy needed -} -``` - -For streaming writes, use `ImmutableInterlocked.Update()` helper: - -```csharp -// In CollectTestsStreamingAsync -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(test)); -yield return test; -``` - -### Benefits - -- Zero lock contention on reads (callers get immutable snapshot) -- Writes use lock-free CAS loop (brief spin during concurrent writes) -- Eliminates defensive copy entirely -- Thread-safe enumeration without locks - -### Trade-offs - -- Slightly higher allocation on writes (new immutable list per update) -- Discovery is infrequent compared to reads, so this is acceptable - ---- - -## Design: ConstraintKeyScheduler - -### Problem 1: LINQ Inside Lock (lines 56-69) - -**Current:** -```csharp -lock (lockObject) -{ - canStart = !constraintKeys.Any(key => lockedKeys.Contains(key)); // LINQ allocation - if (canStart) - { - foreach (var key in constraintKeys) - lockedKeys.Add(key); - } -} -``` - -**Proposed - Manual loop with indexer access:** -```csharp -lock (lockObject) -{ - canStart = true; - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(constraintKeys[i])) - { - canStart = false; - break; // Early exit - } - } - - if (canStart) - { - for (var i = 0; i < keyCount; i++) - lockedKeys.Add(constraintKeys[i]); - } -} -``` - -**Benefits:** -- No delegate allocation -- No enumerator allocation -- Early exit on first conflict - -### Problem 2: Allocations and Extended Lock Scope (lines 149-190) - -**Current:** -```csharp -var testsToStart = new List<...>(); // Outside lock - good - -lock (lockObject) -{ - foreach (var key in constraintKeys) - lockedKeys.Remove(key); - - var tempQueue = new List<...>(); // Allocation INSIDE lock - bad - - while (waitingTests.TryDequeue(out var waitingTest)) - { - var canStart = !waitingTest.ConstraintKeys.Any(...); // LINQ inside lock - // ... extensive work inside lock - } - - foreach (var item in tempQueue) - waitingTests.Enqueue(item); -} -``` - -**Proposed - Two-phase locking with pre-allocation:** - -```csharp -// Pre-allocate outside any lock -var testsToStart = new List<(..., TaskCompletionSource)>(4); -var testsToRequeue = new List<(..., TaskCompletionSource)>(8); -var waitingSnapshot = new List<(..., TaskCompletionSource)>(8); - -// Phase 1: Release keys and snapshot queue (single brief lock) -lock (lockObject) -{ - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - lockedKeys.Remove(constraintKeys[i]); - - while (waitingTests.TryDequeue(out var item)) - waitingSnapshot.Add(item); -} - -// Phase 2: For each candidate, try to acquire keys (brief lock per candidate) -foreach (var waitingTest in waitingSnapshot) -{ - bool acquired; - lock (lockObject) - { - acquired = true; - var keys = waitingTest.ConstraintKeys; - var keyCount = keys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(keys[i])) - { - acquired = false; - break; - } - } - - if (acquired) - { - for (var i = 0; i < keyCount; i++) - lockedKeys.Add(keys[i]); - } - } - - if (acquired) - testsToStart.Add(waitingTest); - else - testsToRequeue.Add(waitingTest); -} - -// Phase 3: Requeue non-starters (single brief lock) -if (testsToRequeue.Count > 0) -{ - lock (lockObject) - { - foreach (var item in testsToRequeue) - waitingTests.Enqueue(item); - } -} - -// Phase 4: Signal starters (outside lock - no contention) -foreach (var test in testsToStart) - test.StartSignal.SetResult(true); -``` - -### Benefits - -- Multiple brief locks instead of one long lock -- Other threads can interleave between phases -- All allocations outside locks -- No LINQ allocations -- Early exit in availability checks - ---- - -## Testing Strategy - -### 1. Stress Tests for Thread Safety - -Add to `TUnit.Engine.Tests`: - -```csharp -[Test] -[Repeat(10)] -public async Task ReflectionTestDataCollector_ConcurrentDiscovery_NoRaceConditions() -{ - var tasks = Enumerable.Range(0, 50) - .Select(_ => collector.CollectTestsAsync(Guid.NewGuid().ToString())); - - var results = await Task.WhenAll(tasks); - - await Assert.That(results.SelectMany(r => r).Distinct().Count()) - .IsGreaterThan(0); -} - -[Test] -[Repeat(10)] -public async Task ConstraintKeyScheduler_HighContention_NoDeadlocks() -{ - var tests = CreateTestsWithOverlappingConstraints(100, overlapFactor: 0.3); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await scheduler.ExecuteTestsWithConstraintsAsync(tests, cts.Token); -} -``` - -### 2. Wall Clock Benchmarks - -```csharp -[Test] -[Category("Performance")] -public async Task Benchmark_TestDiscovery_WallClock() -{ - var stopwatch = Stopwatch.StartNew(); - var tests = await collector.CollectTestsAsync(testSessionId); - stopwatch.Stop(); - - Console.WriteLine($"Discovery wall clock: {stopwatch.ElapsedMilliseconds}ms"); - Console.WriteLine($"Tests discovered: {tests.Count()}"); - Console.WriteLine($"Throughput: {tests.Count() / stopwatch.Elapsed.TotalSeconds:F0} tests/sec"); -} - -[Test] -[Category("Performance")] -public async Task Benchmark_ConstrainedExecution_WallClock() -{ - var tests = CreateTestsWithConstraints(count: 500, constraintOverlap: 0.3); - - var stopwatch = Stopwatch.StartNew(); - await scheduler.ExecuteTestsWithConstraintsAsync(tests, CancellationToken.None); - stopwatch.Stop(); - - Console.WriteLine($"Constrained execution wall clock: {stopwatch.ElapsedMilliseconds}ms"); -} -``` - -### 3. Profiling with dotnet-trace - -```bash -# Baseline (before changes) -dotnet trace collect --name TUnit.PerformanceTests -- dotnet run -c Release -mv trace.nettrace baseline.nettrace - -# Optimized (after changes) -dotnet trace collect --name TUnit.PerformanceTests -- dotnet run -c Release -mv trace.nettrace optimized.nettrace -``` - -### 4. Key Metrics - -| Scenario | Metric | Target | -|----------|--------|--------| -| Discovery (1000 tests) | Wall clock time | >=10% improvement | -| Constrained execution (500 tests, 30% overlap) | Wall clock time | >=15% improvement | -| High parallelism (16+ cores) | Scaling efficiency | Near-linear | -| Lock contention | Thread wait time | >=50% reduction | -| Memory | Hot path allocations | >=30% reduction | - ---- - -## Risk Mitigation - -### Race Condition Risks - -| Risk | Mitigation | -|------|------------| -| ImmutableList CAS loop starvation | Add retry limit with fallback to lock; contention is rare during discovery | -| Lost updates in two-phase lock | Each phase is atomic; tests either start or requeue, never lost | -| Stale reads of `_discoveredTests` | Acceptable - immutable snapshots are always consistent | - -### Behavioral Compatibility - -| Concern | Mitigation | -|---------|------------| -| Test ordering changes | Discovery order was never guaranteed | -| API consumers expecting mutable list | Return type is `IEnumerable`, already read-only contract | -| Constraint scheduling order | Priority ordering preserved | - -### Rollback Strategy - -Both changes are isolated: -- `ReflectionTestDataCollector`: Revert to `List` + lock with single file change -- `ConstraintKeyScheduler`: Revert loop-by-loop if specific optimization causes issues - ---- - -## Implementation Plan - -### Incremental Rollout (Suggested Merge Order) - -1. **PR 1: LINQ to manual loop replacements** (lowest risk) - - Replace `.Any()` with manual loops in ConstraintKeyScheduler - - Immediate benefit, minimal code change - -2. **PR 2: Lock scope restructuring** (medium risk) - - Two-phase locking in ConstraintKeyScheduler - - Pre-allocate lists outside locks - -3. **PR 3: ImmutableList migration** (medium risk) - - Replace List + lock with ImmutableList in ReflectionTestDataCollector - - Atomic swap pattern - -This allows isolating any regressions to specific changes. - ---- - -## Verification Checklist - -- [ ] All existing tests pass -- [ ] Stress tests added and passing -- [ ] Wall clock benchmarks show improvement -- [ ] dotnet-trace shows reduced lock contention -- [ ] No deadlocks under high parallelism (16+ cores) -- [ ] Memory allocations reduced in hot paths diff --git a/docs/plans/2025-12-26-lock-contention-optimization.md b/docs/plans/2025-12-26-lock-contention-optimization.md deleted file mode 100644 index fc88ab6335..0000000000 --- a/docs/plans/2025-12-26-lock-contention-optimization.md +++ /dev/null @@ -1,505 +0,0 @@ -# Lock Contention Optimization Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Reduce lock contention in test discovery and scheduling to improve parallel test execution throughput. - -**Architecture:** Replace `List` + lock with `ImmutableList` + atomic swap in ReflectionTestDataCollector. Replace LINQ with manual loops and restructure to two-phase locking in ConstraintKeyScheduler. - -**Tech Stack:** C#, System.Collections.Immutable, System.Threading - ---- - -## Task 1: Add Stress Tests for Thread Safety Baseline - -**Files:** -- Create: `TUnit.Engine.Tests/Scheduling/ConstraintKeySchedulerConcurrencyTests.cs` - -**Step 1: Write the failing test for high contention scenarios** - -```csharp -using TUnit.Core; -using TUnit.Engine.Scheduling; - -namespace TUnit.Engine.Tests.Scheduling; - -public class ConstraintKeySchedulerConcurrencyTests -{ - [Test] - [Repeat(5)] - public async Task HighContention_WithOverlappingConstraints_CompletesWithoutDeadlock() - { - // Arrange - create mock tests with overlapping constraint keys - // This test establishes baseline behavior before optimization - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - - // Act & Assert - should complete without timeout/deadlock - await Assert.That(async () => - { - // Placeholder - will be implemented after understanding test infrastructure - await Task.Delay(1, cts.Token); - }).ThrowsNothing(); - } -} -``` - -**Step 2: Run test to verify it passes (baseline)** - -Run: `cd TUnit.Engine.Tests && dotnet test --filter "FullyQualifiedName~ConstraintKeySchedulerConcurrencyTests"` -Expected: PASS (baseline test) - -**Step 3: Commit** - -```bash -git add TUnit.Engine.Tests/Scheduling/ConstraintKeySchedulerConcurrencyTests.cs -git commit -m "test: add concurrency stress test baseline for ConstraintKeyScheduler" -``` - ---- - -## Task 2: Replace LINQ with Manual Loops in ConstraintKeyScheduler (lines 56-69) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:56-69` - -**Step 1: Write test for constraint key checking behavior** - -```csharp -[Test] -public async Task ConstraintKeyCheck_WithNoConflicts_StartsImmediately() -{ - // This test verifies the behavior is unchanged after LINQ removal - // Exact implementation depends on testability of ConstraintKeyScheduler -} -``` - -**Step 2: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Replace LINQ `.Any()` with manual loop** - -In `ConstraintKeyScheduler.cs`, change lines 56-69 from: - -```csharp -lock (lockObject) -{ - // Check if all constraint keys are available - canStart = !constraintKeys.Any(key => lockedKeys.Contains(key)); - - if (canStart) - { - // Lock all the constraint keys for this test - foreach (var key in constraintKeys) - { - lockedKeys.Add(key); - } - } -} -``` - -To: - -```csharp -lock (lockObject) -{ - // Check if all constraint keys are available - manual loop avoids LINQ allocation - canStart = true; - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - if (lockedKeys.Contains(constraintKeys[i])) - { - canStart = false; - break; - } - } - - if (canStart) - { - // Lock all the constraint keys for this test - for (var i = 0; i < keyCount; i++) - { - lockedKeys.Add(constraintKeys[i]); - } - } -} -``` - -**Step 4: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: replace LINQ with manual loop in ConstraintKeyScheduler key checking" -``` - ---- - -## Task 3: Replace LINQ in Waiting Test Check (line 165) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:165` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Replace LINQ `.Any()` in waiting test check** - -In `ConstraintKeyScheduler.cs`, change line 165 from: - -```csharp -var canStart = !waitingTest.ConstraintKeys.Any(key => lockedKeys.Contains(key)); -``` - -To: - -```csharp -var canStart = true; -var waitingKeys = waitingTest.ConstraintKeys; -var waitingKeyCount = waitingKeys.Count; -for (var j = 0; j < waitingKeyCount; j++) -{ - if (lockedKeys.Contains(waitingKeys[j])) - { - canStart = false; - break; - } -} -``` - -**Step 3: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 4: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: replace LINQ with manual loop in waiting test availability check" -``` - ---- - -## Task 4: Move List Allocation Outside Lock (lines 149-190) - -**Files:** -- Modify: `TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs:149-190` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Pre-allocate lists outside lock scope** - -Change the structure from allocating `tempQueue` inside lock to pre-allocating outside. Replace lines 149-190: - -```csharp -// Release the constraint keys and check if any waiting tests can now run -var testsToStart = new List<(AbstractExecutableTest Test, IReadOnlyList ConstraintKeys, TaskCompletionSource StartSignal)>(4); -var testsToRequeue = new List<(AbstractExecutableTest Test, IReadOnlyList ConstraintKeys, TaskCompletionSource StartSignal)>(8); - -lock (lockObject) -{ - // Release all constraint keys for this test - var keyCount = constraintKeys.Count; - for (var i = 0; i < keyCount; i++) - { - lockedKeys.Remove(constraintKeys[i]); - } - - // Check waiting tests to see if any can now run - while (waitingTests.TryDequeue(out var waitingTest)) - { - // Check if all constraint keys are available for this waiting test - var canStart = true; - var waitingKeys = waitingTest.ConstraintKeys; - var waitingKeyCount = waitingKeys.Count; - for (var j = 0; j < waitingKeyCount; j++) - { - if (lockedKeys.Contains(waitingKeys[j])) - { - canStart = false; - break; - } - } - - if (canStart) - { - // Lock the keys for this test - for (var j = 0; j < waitingKeyCount; j++) - { - lockedKeys.Add(waitingKeys[j]); - } - - // Mark test to start after we exit the lock - testsToStart.Add(waitingTest); - } - else - { - // Still can't run, keep it for re-queuing - testsToRequeue.Add(waitingTest); - } - } - - // Re-add tests that still can't run - foreach (var waitingTestItem in testsToRequeue) - { - waitingTests.Enqueue(waitingTestItem); - } -} -``` - -**Step 3: Run tests to verify behavior unchanged** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 4: Commit** - -```bash -git add TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs -git commit -m "perf: pre-allocate lists outside lock scope in ConstraintKeyScheduler" -``` - ---- - -## Task 5: Add ImmutableList to ReflectionTestDataCollector - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:31-32` - -**Step 1: Run existing tests to establish baseline** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 2: Add System.Collections.Immutable using and change field declaration** - -At the top of `ReflectionTestDataCollector.cs`, ensure this using is present: - -```csharp -using System.Collections.Immutable; -``` - -Change lines 31-32 from: - -```csharp -private static readonly List _discoveredTests = new(capacity: 1000); -private static readonly Lock _discoveredTestsLock = new(); -``` - -To: - -```csharp -private static ImmutableList _discoveredTests = ImmutableList.Empty; -``` - -**Step 3: Run tests (will fail - fields referenced elsewhere)** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: FAIL (compilation errors due to field changes) - -**Step 4: Commit partial change** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "refactor: change _discoveredTests to ImmutableList (WIP)" -``` - ---- - -## Task 6: Update CollectTestsAsync for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:136-141` - -**Step 1: Replace lock with atomic swap** - -Change lines 136-141 from: - -```csharp -// Add to discovered tests with lock -lock (_discoveredTestsLock) -{ - _discoveredTests.AddRange(newTests); - return new List(_discoveredTests); -} -``` - -To: - -```csharp -// Atomic swap - no lock needed for readers -ImmutableList original, updated; -do -{ - original = _discoveredTests; - updated = original.AddRange(newTests); -} while (Interlocked.CompareExchange(ref _discoveredTests, updated, original) != original); - -return _discoveredTests; -``` - -**Step 2: Run tests (may still fail if other usages remain)** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: May fail if streaming methods still use old lock - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: use atomic swap for CollectTestsAsync" -``` - ---- - -## Task 7: Update CollectTestsStreamingAsync for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:174-190` - -**Step 1: Replace lock with ImmutableInterlocked.Update** - -Change lines 174-178 from: - -```csharp -lock (_discoveredTestsLock) -{ - _discoveredTests.Add(test); -} -yield return test; -``` - -To: - -```csharp -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(test)); -yield return test; -``` - -Similarly for lines 185-188: - -```csharp -ImmutableInterlocked.Update(ref _discoveredTests, list => list.Add(dynamicTest)); -yield return dynamicTest; -``` - -**Step 2: Run tests to verify streaming works** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: use ImmutableInterlocked.Update for streaming discovery" -``` - ---- - -## Task 8: Update ClearCaches for ImmutableList - -**Files:** -- Modify: `TUnit.Engine/Discovery/ReflectionTestDataCollector.cs:47-60` - -**Step 1: Simplify ClearCaches to use atomic assignment** - -Change lines 50-53 from: - -```csharp -lock (_discoveredTestsLock) -{ - _discoveredTests.Clear(); -} -``` - -To: - -```csharp -Interlocked.Exchange(ref _discoveredTests, ImmutableList.Empty); -``` - -**Step 2: Run all tests** - -Run: `cd TUnit.Engine.Tests && dotnet test` -Expected: All tests PASS - -**Step 3: Commit** - -```bash -git add TUnit.Engine/Discovery/ReflectionTestDataCollector.cs -git commit -m "perf: simplify ClearCaches with atomic exchange" -``` - ---- - -## Task 9: Run Full Test Suite and Performance Verification - -**Files:** -- None (verification only) - -**Step 1: Run full TUnit test suite** - -Run: `dotnet test` -Expected: All tests PASS - -**Step 2: Run performance tests with dotnet-trace** - -```bash -cd TUnit.PerformanceTests -dotnet trace collect -- dotnet run -c Release -``` - -**Step 3: Verify no regressions** - -Compare trace output with baseline (if available). Look for: -- Reduced lock contention time -- Reduced thread wait time -- Similar or better wall clock time - -**Step 4: Final commit with summary** - -```bash -git add -A -git commit -m "perf: reduce lock contention in test discovery and scheduling - -Implements optimizations for #4162: -- ReflectionTestDataCollector: ImmutableList with atomic swap -- ConstraintKeyScheduler: manual loops replacing LINQ, pre-allocated lists - -This eliminates defensive copies under lock and reduces LINQ allocations -in hot paths during parallel test execution." -``` - ---- - -## Verification Checklist - -- [ ] All existing tests pass -- [ ] Stress tests pass under high contention -- [ ] No deadlocks under parallel execution -- [ ] Wall clock time equal or improved -- [ ] Lock contention reduced (verify with dotnet-trace) - ---- - -Plan complete and saved to `docs/plans/2025-12-26-lock-contention-optimization.md`. Two execution options: - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/plans/2025-12-28-json-assertions-design.md b/docs/plans/2025-12-28-json-assertions-design.md deleted file mode 100644 index 40f3b62377..0000000000 --- a/docs/plans/2025-12-28-json-assertions-design.md +++ /dev/null @@ -1,230 +0,0 @@ -# JSON Assertions Design - -**Issue**: [#4178 - JsonElement assertions](https://github.com/thomhurst/TUnit/issues/4178) -**Date**: 2025-12-28 -**Status**: Approved Design - -## Summary - -Add comprehensive JSON assertions to TUnit supporting both `JsonElement` and `JsonNode` type hierarchies, with runtime-appropriate features via conditional compilation. - -## Problem Statement - -Currently, `Assert.That(json1).IsEqualTo(json2)` performs string comparison on JSON, which: -- Fails on semantically equivalent JSON with different whitespace -- Provides unhelpful error messages that don't indicate where differences occur - -Users need: -- Semantic JSON comparison (ignoring formatting) -- Detailed error messages with paths to differences (e.g., "differs at $.abc.def") - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Target Types | Both JsonElement and JsonNode | Complete coverage for all System.Text.Json users | -| Equality Logic | Built-in `DeepEquals` | Leverage .NET's tested implementation | -| Runtime Support | Per-runtime via `#if` | Provide what's available without polyfills | -| Assertion Categories | Equality, Validity, Property, Type, Array | Comprehensive without external dependencies | -| Path Queries | Deferred | JSONPath not built-in, avoid dependencies | -| Error Messages | Path-to-difference | Addresses core issue request | -| API Entry Point | `Assert.That()` extensions | Consistent with TUnit patterns | -| Package Location | Inside TUnit.Assertions | No extra package for users | -| Implementation | `[GenerateAssertion]` | Simplified code, source generator handles infrastructure | - -## Runtime Availability Matrix - -| Assertion | .NET 6-7 | .NET 8 | .NET 9+ | -|-----------|----------|--------|---------| -| `IsEqualTo` (JsonNode) | - | Y | Y | -| `IsEqualTo` (JsonElement) | - | - | Y | -| `IsValidJson` (string) | Y | Y | Y | -| `HasProperty` | Y | Y | Y | -| Type checks (`IsObject`, etc.) | Y | Y | Y | -| Array assertions | Y | Y | Y | - -## File Structure - -``` -TUnit.Assertions/Conditions/Json/ -├── JsonElementAssertionExtensions.cs -├── JsonNodeAssertionExtensions.cs -├── JsonStringAssertionExtensions.cs -└── JsonDiffHelper.cs -``` - -## Assertion Inventory - -### JsonElement Assertions - -```csharp -// Type checking (all runtimes) -IsObject() -IsArray() -IsString() -IsNumber() -IsBoolean() -IsNull() -IsNotNull() - -// Property access (all runtimes) -HasProperty(string propertyName) -DoesNotHaveProperty(string propertyName) - -// Equality (.NET 9+ only) -#if NET9_0_OR_GREATER -IsEqualTo(JsonElement expected) -IsNotEqualTo(JsonElement expected) -#endif -``` - -### JsonNode Assertions - -```csharp -// Type checking (all runtimes) -IsObject() -IsArray() -IsValue() - -// Property access (all runtimes) -HasProperty(string propertyName) -DoesNotHaveProperty(string propertyName) - -// Equality (.NET 8+ only) -#if NET8_0_OR_GREATER -IsEqualTo(JsonNode? expected) -IsNotEqualTo(JsonNode? expected) -#endif -``` - -### JsonArray Assertions - -```csharp -// All runtimes -IsEmpty() -IsNotEmpty() -HasCount(int expected) -``` - -### String (Raw JSON) Assertions - -```csharp -// All runtimes -IsValidJson() -IsNotValidJson() -IsValidJsonObject() -IsValidJsonArray() -``` - -## Implementation Pattern - -Using `[GenerateAssertion]` for simplified code: - -```csharp -using System.Text.Json; -using TUnit.Assertions.Attributes; -using TUnit.Assertions.Core; - -namespace TUnit.Assertions.Conditions.Json; - -file static partial class JsonElementAssertionExtensions -{ - [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] - public static bool IsObject(this JsonElement value) - => value.ValueKind == JsonValueKind.Object; - - [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"")] - public static bool HasProperty(this JsonElement value, string propertyName) - => value.TryGetProperty(propertyName, out _); - -#if NET9_0_OR_GREATER - [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] - public static AssertionResult IsEqualTo(this JsonElement value, JsonElement expected) - { - if (JsonElement.DeepEquals(value, expected)) - return AssertionResult.Passed; - - var diff = JsonDiffHelper.FindFirstDifference(value, expected); - return AssertionResult.Failed( - $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); - } -#endif -} -``` - -## JsonDiffHelper - -Provides path-to-difference for error messages: - -```csharp -internal static class JsonDiffHelper -{ - public readonly record struct DiffResult(string Path, string Expected, string Actual); - - public static DiffResult FindFirstDifference(JsonElement left, JsonElement right) - { - return FindDiff(left, right, "$"); - } - - private static DiffResult FindDiff(JsonElement left, JsonElement right, string path) - { - if (left.ValueKind != right.ValueKind) - return new DiffResult(path, right.ValueKind.ToString(), left.ValueKind.ToString()); - - return left.ValueKind switch - { - JsonValueKind.Object => CompareObjects(left, right, path), - JsonValueKind.Array => CompareArrays(left, right, path), - _ => ComparePrimitives(left, right, path) - }; - } - // ... implementation details -} -``` - -## Error Message Examples - -``` -Expected JSON to be equal to {"name":"Alice","age":31} -but differs at $.age: expected 31 but found 30 - -Expected JSON to be equal to {"users":[{"id":1}]} -but differs at $.users[0].id: expected 1 but found 2 - -Expected JSON to be equal to {"active":true} -but differs at $.active: expected True but found False -``` - -## Usage Examples - -```csharp -// Type checking -await Assert.That(element).IsObject(); -await Assert.That(node).IsArray(); - -// Property access -await Assert.That(element).HasProperty("name"); -await Assert.That(element).DoesNotHaveProperty("deleted"); - -// Equality (.NET 8+/9+) -await Assert.That(element).IsEqualTo(expectedElement); -await Assert.That(node).IsEqualTo(expectedNode); - -// String validation -await Assert.That(jsonString).IsValidJson(); -await Assert.That(jsonString).IsValidJsonObject(); -``` - -## Testing Strategy - -- Unit tests in `TUnit.Assertions.Tests` with `#if` for runtime-specific tests -- Test both pass and fail scenarios for each assertion -- Verify error message format includes correct JSON paths -- Multi-target test project to validate behavior on each runtime - -## Future Considerations - -- JSONPath support via optional dependency or built-in simple path syntax -- `HasPropertyWithValue(name, value)` combined assertion -- JSON Schema validation -- Partial matching / subset assertions diff --git a/docs/plans/2025-12-28-json-assertions.md b/docs/plans/2025-12-28-json-assertions.md deleted file mode 100644 index 7c728655a2..0000000000 --- a/docs/plans/2025-12-28-json-assertions.md +++ /dev/null @@ -1,991 +0,0 @@ -# JSON Assertions Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add comprehensive JSON assertions for JsonElement, JsonNode, and string types with semantic equality and path-to-difference error messages. - -**Architecture:** Use `[GenerateAssertion]` attribute pattern for all assertions. Conditional compilation (`#if NET8_0_OR_GREATER`, `#if NET9_0_OR_GREATER`) enables runtime-specific features. JsonDiffHelper provides detailed error messages showing where JSON structures differ. - -**Tech Stack:** System.Text.Json (built-in), TUnit.Assertions source generator, C# 12 - ---- - -## Task 1: Create JsonDiffHelper - -**Files:** -- Create: `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs` - -**Step 1: Create the Json directory** - -```bash -mkdir TUnit.Assertions/Conditions/Json -``` - -**Step 2: Write the JsonDiffHelper** - -Create `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs`: - -```csharp -using System.Text.Json; - -namespace TUnit.Assertions.Conditions.Json; - -internal static class JsonDiffHelper -{ - public readonly record struct DiffResult(string Path, string Expected, string Actual); - - public static DiffResult FindFirstDifference(JsonElement left, JsonElement right) - { - return FindDiff(left, right, "$"); - } - - private static DiffResult FindDiff(JsonElement left, JsonElement right, string path) - { - if (left.ValueKind != right.ValueKind) - { - return new DiffResult(path, right.ValueKind.ToString(), left.ValueKind.ToString()); - } - - return left.ValueKind switch - { - JsonValueKind.Object => CompareObjects(left, right, path), - JsonValueKind.Array => CompareArrays(left, right, path), - _ => ComparePrimitives(left, right, path) - }; - } - - private static DiffResult CompareObjects(JsonElement left, JsonElement right, string path) - { - // Check for missing properties in left that exist in right - foreach (var prop in right.EnumerateObject()) - { - var propPath = $"{path}.{prop.Name}"; - if (!left.TryGetProperty(prop.Name, out var leftProp)) - { - return new DiffResult(propPath, FormatValue(prop.Value), "(missing)"); - } - - var diff = FindDiff(leftProp, prop.Value, propPath); - if (!string.IsNullOrEmpty(diff.Expected) || !string.IsNullOrEmpty(diff.Actual)) - { - return diff; - } - } - - // Check for extra properties in left that don't exist in right - foreach (var prop in left.EnumerateObject()) - { - var propPath = $"{path}.{prop.Name}"; - if (!right.TryGetProperty(prop.Name, out _)) - { - return new DiffResult(propPath, "(missing)", FormatValue(prop.Value)); - } - } - - return default; - } - - private static DiffResult CompareArrays(JsonElement left, JsonElement right, string path) - { - var leftLength = left.GetArrayLength(); - var rightLength = right.GetArrayLength(); - - if (leftLength != rightLength) - { - return new DiffResult($"{path}.Length", rightLength.ToString(), leftLength.ToString()); - } - - var leftEnumerator = left.EnumerateArray(); - var rightEnumerator = right.EnumerateArray(); - var index = 0; - - while (leftEnumerator.MoveNext() && rightEnumerator.MoveNext()) - { - var itemPath = $"{path}[{index}]"; - var diff = FindDiff(leftEnumerator.Current, rightEnumerator.Current, itemPath); - if (!string.IsNullOrEmpty(diff.Expected) || !string.IsNullOrEmpty(diff.Actual)) - { - return diff; - } - index++; - } - - return default; - } - - private static DiffResult ComparePrimitives(JsonElement left, JsonElement right, string path) - { - var leftText = FormatValue(left); - var rightText = FormatValue(right); - - if (leftText != rightText) - { - return new DiffResult(path, rightText, leftText); - } - - return default; - } - - private static string FormatValue(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.String => $"\"{element.GetString()}\"", - JsonValueKind.Number => element.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - JsonValueKind.Null => "null", - JsonValueKind.Object => "{...}", - JsonValueKind.Array => "[...]", - _ => element.GetRawText() - }; - } -} -``` - -**Step 3: Verify it compiles** - -```bash -cd C:/git/TUnit && dotnet build TUnit.Assertions/TUnit.Assertions.csproj --no-restore -v q -``` - -Expected: Build succeeded - -**Step 4: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs -git commit -m "feat(assertions): add JsonDiffHelper for path-to-difference error messages" -``` - ---- - -## Task 2: Create JsonElement Type Assertions - -**Files:** -- Create: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` -- Test: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` - -**Step 1: Write the failing test** - -Create `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: - -```csharp -using System.Text.Json; -using TUnit.Assertions.Extensions; - -namespace TUnit.Assertions.Tests; - -public class JsonElementAssertionTests -{ - [Test] - public async Task IsObject_WithObject_Passes() - { - using var doc = JsonDocument.Parse("{\"name\":\"test\"}"); - await Assert.That(doc.RootElement).IsObject(); - } - - [Test] - public async Task IsObject_WithArray_Fails() - { - using var doc = JsonDocument.Parse("[1,2,3]"); - await Assert.ThrowsAsync( - async () => await Assert.That(doc.RootElement).IsObject()); - } - - [Test] - public async Task IsArray_WithArray_Passes() - { - using var doc = JsonDocument.Parse("[1,2,3]"); - await Assert.That(doc.RootElement).IsArray(); - } - - [Test] - public async Task IsString_WithString_Passes() - { - using var doc = JsonDocument.Parse("\"hello\""); - await Assert.That(doc.RootElement).IsString(); - } - - [Test] - public async Task IsNumber_WithNumber_Passes() - { - using var doc = JsonDocument.Parse("42"); - await Assert.That(doc.RootElement).IsNumber(); - } - - [Test] - public async Task IsBoolean_WithTrue_Passes() - { - using var doc = JsonDocument.Parse("true"); - await Assert.That(doc.RootElement).IsBoolean(); - } - - [Test] - public async Task IsNull_WithNull_Passes() - { - using var doc = JsonDocument.Parse("null"); - await Assert.That(doc.RootElement).IsNull(); - } - - [Test] - public async Task IsNotNull_WithObject_Passes() - { - using var doc = JsonDocument.Parse("{}"); - await Assert.That(doc.RootElement).IsNotNull(); - } -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" --no-build -``` - -Expected: FAIL - IsObject method not found - -**Step 3: Write the JsonElement type assertions** - -Create `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: - -```csharp -using System.Text.Json; -using TUnit.Assertions.Attributes; - -namespace TUnit.Assertions.Conditions.Json; - -/// -/// Source-generated assertions for JsonElement type checking. -/// -file static partial class JsonElementAssertionExtensions -{ - [GenerateAssertion(ExpectationMessage = "to be a JSON object", InlineMethodBody = true)] - public static bool IsObject(this JsonElement value) - => value.ValueKind == JsonValueKind.Object; - - [GenerateAssertion(ExpectationMessage = "to be a JSON array", InlineMethodBody = true)] - public static bool IsArray(this JsonElement value) - => value.ValueKind == JsonValueKind.Array; - - [GenerateAssertion(ExpectationMessage = "to be a JSON string", InlineMethodBody = true)] - public static bool IsString(this JsonElement value) - => value.ValueKind == JsonValueKind.String; - - [GenerateAssertion(ExpectationMessage = "to be a JSON number", InlineMethodBody = true)] - public static bool IsNumber(this JsonElement value) - => value.ValueKind == JsonValueKind.Number; - - [GenerateAssertion(ExpectationMessage = "to be a JSON boolean", InlineMethodBody = true)] - public static bool IsBoolean(this JsonElement value) - => value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False; - - [GenerateAssertion(ExpectationMessage = "to be JSON null", InlineMethodBody = true)] - public static bool IsNull(this JsonElement value) - => value.ValueKind == JsonValueKind.Null; - - [GenerateAssertion(ExpectationMessage = "to not be JSON null", InlineMethodBody = true)] - public static bool IsNotNull(this JsonElement value) - => value.ValueKind != JsonValueKind.Null; -} -``` - -**Step 4: Build to generate extension methods** - -```bash -cd C:/git/TUnit && dotnet build TUnit.Assertions/TUnit.Assertions.csproj -v q -``` - -Expected: Build succeeded - -**Step 5: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" -``` - -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs -git commit -m "feat(assertions): add JsonElement type checking assertions" -``` - ---- - -## Task 3: Add JsonElement Property Assertions - -**Files:** -- Modify: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` -- Modify: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` - -**Step 1: Write the failing tests** - -Add to `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: - -```csharp - [Test] - public async Task HasProperty_WhenPropertyExists_Passes() - { - using var doc = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); - await Assert.That(doc.RootElement).HasProperty("name"); - } - - [Test] - public async Task HasProperty_WhenPropertyMissing_Fails() - { - using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); - await Assert.ThrowsAsync( - async () => await Assert.That(doc.RootElement).HasProperty("missing")); - } - - [Test] - public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() - { - using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); - await Assert.That(doc.RootElement).DoesNotHaveProperty("missing"); - } - - [Test] - public async Task DoesNotHaveProperty_WhenPropertyExists_Fails() - { - using var doc = JsonDocument.Parse("{\"name\":\"Alice\"}"); - await Assert.ThrowsAsync( - async () => await Assert.That(doc.RootElement).DoesNotHaveProperty("name")); - } -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests.HasProperty" --no-build -``` - -Expected: FAIL - HasProperty method not found - -**Step 3: Add property assertions** - -Add to `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: - -```csharp - [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"", InlineMethodBody = true)] - public static bool HasProperty(this JsonElement value, string propertyName) - => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(propertyName, out _); - - [GenerateAssertion(ExpectationMessage = "to not have property \"{propertyName}\"", InlineMethodBody = true)] - public static bool DoesNotHaveProperty(this JsonElement value, string propertyName) - => value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(propertyName, out _); -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs -git commit -m "feat(assertions): add JsonElement property assertions" -``` - ---- - -## Task 4: Add JsonElement Equality Assertions (.NET 9+) - -**Files:** -- Modify: `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` -- Modify: `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` - -**Step 1: Write the failing tests** - -Add to `TUnit.Assertions.Tests/JsonElementAssertionTests.cs`: - -```csharp -#if NET9_0_OR_GREATER - [Test] - public async Task IsEqualTo_WithIdenticalJson_Passes() - { - using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); - using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); - await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement); - } - - [Test] - public async Task IsEqualTo_WithDifferentWhitespace_Passes() - { - using var doc1 = JsonDocument.Parse("{ \"name\" : \"Alice\" }"); - using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\"}"); - await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement); - } - - [Test] - public async Task IsEqualTo_WithDifferentValues_FailsWithPath() - { - using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":30}"); - using var doc2 = JsonDocument.Parse("{\"name\":\"Alice\",\"age\":31}"); - - var exception = await Assert.ThrowsAsync( - async () => await Assert.That(doc1.RootElement).IsEqualTo(doc2.RootElement)); - - await Assert.That(exception.Message).Contains("$.age"); - } - - [Test] - public async Task IsNotEqualTo_WithDifferentJson_Passes() - { - using var doc1 = JsonDocument.Parse("{\"name\":\"Alice\"}"); - using var doc2 = JsonDocument.Parse("{\"name\":\"Bob\"}"); - await Assert.That(doc1.RootElement).IsNotEqualTo(doc2.RootElement); - } -#endif -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests.IsEqualTo" --framework net9.0 --no-build -``` - -Expected: FAIL - IsEqualTo method not found - -**Step 3: Add equality assertions** - -Add to `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs`: - -```csharp -#if NET9_0_OR_GREATER - [GenerateAssertion(ExpectationMessage = "to be equal to {expected}")] - public static TUnit.Assertions.Core.AssertionResult IsEqualTo(this JsonElement value, JsonElement expected) - { - if (JsonElement.DeepEquals(value, expected)) - { - return TUnit.Assertions.Core.AssertionResult.Passed; - } - - var diff = JsonDiffHelper.FindFirstDifference(value, expected); - return TUnit.Assertions.Core.AssertionResult.Failed( - $"differs at {diff.Path}: expected {diff.Expected} but found {diff.Actual}"); - } - - [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}", InlineMethodBody = true)] - public static bool IsNotEqualTo(this JsonElement value, JsonElement expected) - => !JsonElement.DeepEquals(value, expected); -#endif -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonElementAssertionTests" --framework net9.0 -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs TUnit.Assertions.Tests/JsonElementAssertionTests.cs -git commit -m "feat(assertions): add JsonElement equality assertions for .NET 9+" -``` - ---- - -## Task 5: Create JsonNode Assertions - -**Files:** -- Create: `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` -- Create: `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` - -**Step 1: Write the failing test** - -Create `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs`: - -```csharp -using System.Text.Json.Nodes; -using TUnit.Assertions.Extensions; - -namespace TUnit.Assertions.Tests; - -public class JsonNodeAssertionTests -{ - [Test] - public async Task IsObject_WithJsonObject_Passes() - { - JsonNode? node = JsonNode.Parse("{\"name\":\"test\"}"); - await Assert.That(node).IsJsonObject(); - } - - [Test] - public async Task IsArray_WithJsonArray_Passes() - { - JsonNode? node = JsonNode.Parse("[1,2,3]"); - await Assert.That(node).IsJsonArray(); - } - - [Test] - public async Task IsValue_WithJsonValue_Passes() - { - JsonNode? node = JsonNode.Parse("42"); - await Assert.That(node).IsJsonValue(); - } - - [Test] - public async Task HasProperty_WhenPropertyExists_Passes() - { - JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); - await Assert.That(node).HasJsonProperty("name"); - } - - [Test] - public async Task DoesNotHaveProperty_WhenPropertyMissing_Passes() - { - JsonNode? node = JsonNode.Parse("{\"name\":\"Alice\"}"); - await Assert.That(node).DoesNotHaveJsonProperty("missing"); - } - -#if NET8_0_OR_GREATER - [Test] - public async Task IsEqualTo_WithIdenticalJson_Passes() - { - JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); - JsonNode? node2 = JsonNode.Parse("{\"name\":\"Alice\",\"age\":30}"); - await Assert.That(node1).IsEqualTo(node2); - } - - [Test] - public async Task IsNotEqualTo_WithDifferentJson_Passes() - { - JsonNode? node1 = JsonNode.Parse("{\"name\":\"Alice\"}"); - JsonNode? node2 = JsonNode.Parse("{\"name\":\"Bob\"}"); - await Assert.That(node1).IsNotEqualTo(node2); - } -#endif -} -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" --no-build -``` - -Expected: FAIL - methods not found - -**Step 3: Write the JsonNode assertions** - -Create `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs`: - -```csharp -using System.Text.Json.Nodes; -using TUnit.Assertions.Attributes; - -namespace TUnit.Assertions.Conditions.Json; - -/// -/// Source-generated assertions for JsonNode types. -/// -file static partial class JsonNodeAssertionExtensions -{ - [GenerateAssertion(ExpectationMessage = "to be a JsonObject", InlineMethodBody = true)] - public static bool IsJsonObject(this JsonNode? value) - => value is JsonObject; - - [GenerateAssertion(ExpectationMessage = "to be a JsonArray", InlineMethodBody = true)] - public static bool IsJsonArray(this JsonNode? value) - => value is JsonArray; - - [GenerateAssertion(ExpectationMessage = "to be a JsonValue", InlineMethodBody = true)] - public static bool IsJsonValue(this JsonNode? value) - => value is JsonValue; - - [GenerateAssertion(ExpectationMessage = "to have property \"{propertyName}\"", InlineMethodBody = true)] - public static bool HasJsonProperty(this JsonNode? value, string propertyName) - => value is JsonObject obj && obj.ContainsKey(propertyName); - - [GenerateAssertion(ExpectationMessage = "to not have property \"{propertyName}\"", InlineMethodBody = true)] - public static bool DoesNotHaveJsonProperty(this JsonNode? value, string propertyName) - => value is not JsonObject obj || !obj.ContainsKey(propertyName); - -#if NET8_0_OR_GREATER - [GenerateAssertion(ExpectationMessage = "to be equal to {expected}", InlineMethodBody = true)] - public static bool IsEqualTo(this JsonNode? value, JsonNode? expected) - => JsonNode.DeepEquals(value, expected); - - [GenerateAssertion(ExpectationMessage = "to not be equal to {expected}", InlineMethodBody = true)] - public static bool IsNotEqualTo(this JsonNode? value, JsonNode? expected) - => !JsonNode.DeepEquals(value, expected); -#endif -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs TUnit.Assertions.Tests/JsonNodeAssertionTests.cs -git commit -m "feat(assertions): add JsonNode assertions with equality for .NET 8+" -``` - ---- - -## Task 6: Create JsonArray Assertions - -**Files:** -- Modify: `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` -- Modify: `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` - -**Step 1: Write the failing tests** - -Add to `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs`: - -```csharp - [Test] - public async Task IsEmpty_WithEmptyArray_Passes() - { - var array = new JsonArray(); - await Assert.That(array).IsJsonArrayEmpty(); - } - - [Test] - public async Task IsNotEmpty_WithNonEmptyArray_Passes() - { - var array = new JsonArray(1, 2, 3); - await Assert.That(array).IsJsonArrayNotEmpty(); - } - - [Test] - public async Task HasCount_WithMatchingCount_Passes() - { - var array = new JsonArray(1, 2, 3); - await Assert.That(array).HasJsonArrayCount(3); - } -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests.IsEmpty" --no-build -``` - -Expected: FAIL - methods not found - -**Step 3: Add JsonArray assertions** - -Add to `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs`: - -```csharp - [GenerateAssertion(ExpectationMessage = "to be an empty JSON array", InlineMethodBody = true)] - public static bool IsJsonArrayEmpty(this JsonArray value) - => value.Count == 0; - - [GenerateAssertion(ExpectationMessage = "to not be an empty JSON array", InlineMethodBody = true)] - public static bool IsJsonArrayNotEmpty(this JsonArray value) - => value.Count > 0; - - [GenerateAssertion(ExpectationMessage = "to have {expected} elements")] - public static TUnit.Assertions.Core.AssertionResult HasJsonArrayCount(this JsonArray value, int expected) - { - if (value.Count == expected) - { - return TUnit.Assertions.Core.AssertionResult.Passed; - } - return TUnit.Assertions.Core.AssertionResult.Failed($"has {value.Count} elements"); - } -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonNodeAssertionTests" -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs TUnit.Assertions.Tests/JsonNodeAssertionTests.cs -git commit -m "feat(assertions): add JsonArray count and empty assertions" -``` - ---- - -## Task 7: Create String JSON Validation Assertions - -**Files:** -- Create: `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs` -- Create: `TUnit.Assertions.Tests/JsonStringAssertionTests.cs` - -**Step 1: Write the failing test** - -Create `TUnit.Assertions.Tests/JsonStringAssertionTests.cs`: - -```csharp -using TUnit.Assertions.Extensions; - -namespace TUnit.Assertions.Tests; - -public class JsonStringAssertionTests -{ - [Test] - public async Task IsValidJson_WithValidJson_Passes() - { - var json = "{\"name\":\"Alice\"}"; - await Assert.That(json).IsValidJson(); - } - - [Test] - public async Task IsValidJson_WithInvalidJson_Fails() - { - var json = "not valid json"; - await Assert.ThrowsAsync( - async () => await Assert.That(json).IsValidJson()); - } - - [Test] - public async Task IsNotValidJson_WithInvalidJson_Passes() - { - var json = "not valid json"; - await Assert.That(json).IsNotValidJson(); - } - - [Test] - public async Task IsValidJsonObject_WithObject_Passes() - { - var json = "{\"name\":\"Alice\"}"; - await Assert.That(json).IsValidJsonObject(); - } - - [Test] - public async Task IsValidJsonObject_WithArray_Fails() - { - var json = "[1,2,3]"; - await Assert.ThrowsAsync( - async () => await Assert.That(json).IsValidJsonObject()); - } - - [Test] - public async Task IsValidJsonArray_WithArray_Passes() - { - var json = "[1,2,3]"; - await Assert.That(json).IsValidJsonArray(); - } - - [Test] - public async Task IsValidJsonArray_WithObject_Fails() - { - var json = "{\"name\":\"Alice\"}"; - await Assert.ThrowsAsync( - async () => await Assert.That(json).IsValidJsonArray()); - } -} -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonStringAssertionTests" --no-build -``` - -Expected: FAIL - methods not found - -**Step 3: Write the string JSON assertions** - -Create `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs`: - -```csharp -using System.Text.Json; -using TUnit.Assertions.Attributes; -using TUnit.Assertions.Core; - -namespace TUnit.Assertions.Conditions.Json; - -/// -/// Source-generated assertions for validating JSON strings. -/// -file static partial class JsonStringAssertionExtensions -{ - [GenerateAssertion(ExpectationMessage = "to be valid JSON")] - public static AssertionResult IsValidJson(this string value) - { - try - { - using var doc = JsonDocument.Parse(value); - return AssertionResult.Passed; - } - catch (JsonException ex) - { - return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); - } - } - - [GenerateAssertion(ExpectationMessage = "to not be valid JSON")] - public static AssertionResult IsNotValidJson(this string value) - { - try - { - using var doc = JsonDocument.Parse(value); - return AssertionResult.Failed("is valid JSON"); - } - catch (JsonException) - { - return AssertionResult.Passed; - } - } - - [GenerateAssertion(ExpectationMessage = "to be a valid JSON object")] - public static AssertionResult IsValidJsonObject(this string value) - { - try - { - using var doc = JsonDocument.Parse(value); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - return AssertionResult.Passed; - } - return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Object"); - } - catch (JsonException ex) - { - return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); - } - } - - [GenerateAssertion(ExpectationMessage = "to be a valid JSON array")] - public static AssertionResult IsValidJsonArray(this string value) - { - try - { - using var doc = JsonDocument.Parse(value); - if (doc.RootElement.ValueKind == JsonValueKind.Array) - { - return AssertionResult.Passed; - } - return AssertionResult.Failed($"is a {doc.RootElement.ValueKind}, not an Array"); - } - catch (JsonException ex) - { - return AssertionResult.Failed($"is not valid JSON: {ex.Message}"); - } - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj --filter "FullyQualifiedName~JsonStringAssertionTests" -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs TUnit.Assertions.Tests/JsonStringAssertionTests.cs -git commit -m "feat(assertions): add JSON string validation assertions" -``` - ---- - -## Task 8: Run Full Test Suite and Update Snapshots - -**Files:** -- May modify: `TUnit.PublicAPI/**/*.verified.txt` -- May modify: `TUnit.Core.SourceGenerator.Tests/**/*.verified.txt` - -**Step 1: Run full assertion tests** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj -``` - -Expected: All tests pass - -**Step 2: Run public API tests** - -```bash -cd C:/git/TUnit && dotnet test TUnit.PublicAPI/TUnit.PublicAPI.csproj -``` - -Expected: May fail if new public APIs are detected - -**Step 3: Accept snapshots if needed** - -```bash -cd C:/git/TUnit/TUnit.PublicAPI -for %f in (*.received.txt) do move /Y "%f" "%~nf.verified.txt" -``` - -**Step 4: Run source generator tests** - -```bash -cd C:/git/TUnit && dotnet test TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj -``` - -Expected: Should pass (JSON assertions don't affect source generator) - -**Step 5: Commit snapshots** - -```bash -git add TUnit.PublicAPI/*.verified.txt -git commit -m "chore: update public API snapshots for JSON assertions" -``` - ---- - -## Task 9: Final Verification - -**Step 1: Run complete test suite** - -```bash -cd C:/git/TUnit && dotnet test -``` - -Expected: All tests pass - -**Step 2: Test AOT compatibility** - -```bash -cd C:/git/TUnit/TUnit.TestProject && dotnet publish -c Release -p:PublishAot=true --use-current-runtime -``` - -Expected: Publish succeeds - -**Step 3: Final commit if any cleanup needed** - -```bash -git status -# If clean, no action needed -# If changes, commit them -``` - ---- - -## Summary - -This plan creates 4 new files: -- `TUnit.Assertions/Conditions/Json/JsonDiffHelper.cs` - Path-to-difference logic -- `TUnit.Assertions/Conditions/Json/JsonElementAssertionExtensions.cs` - JsonElement assertions -- `TUnit.Assertions/Conditions/Json/JsonNodeAssertionExtensions.cs` - JsonNode/JsonArray assertions -- `TUnit.Assertions/Conditions/Json/JsonStringAssertionExtensions.cs` - String validation assertions - -And 3 new test files: -- `TUnit.Assertions.Tests/JsonElementAssertionTests.cs` -- `TUnit.Assertions.Tests/JsonNodeAssertionTests.cs` -- `TUnit.Assertions.Tests/JsonStringAssertionTests.cs` - -Total assertions: ~20 across all types with runtime-specific availability.