PQSoft.JsonComparer 1.2.0-beta.1

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

PQSoft.JsonComparer

A powerful library for structural JSON comparison designed for testing REST APIs, validating service responses, and comparing JSON documents received as strings or bytes.

Core Value: Structural JSON Comparison

The primary purpose of JsonComparer is to compare JSON documents structurally, regardless of formatting, property order, or whitespace differences:

// These are structurally identical despite different formatting
string apiResponse = """{"name":"John","age":30,"active":true}""";
string expected = """
{
    "name": "John",
    "age": 30,
    "active": true
}
""";

var comparer = new JsonComparer();
var result = comparer.ExactMatch(expected, apiResponse);
// result.IsMatch = true - structure and values match

Perfect for REST API Testing

When testing REST endpoints, you receive JSON as strings or bytes. JsonComparer handles this seamlessly:

// Test a REST API endpoint
var response = await httpClient.GetAsync("/api/users/123");
string responseJson = await response.Content.ReadAsStringAsync();

string expectedStructure = """
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com",
    "status": "active"
}
""";

var result = comparer.ExactMatch(expectedStructure, responseJson);
Assert.True(result.IsMatch);

Subset Matching for Contract Validation

Validate that required fields exist without caring about extra fields:

// API might return additional fields - that's OK
string apiResponse = """
{
    "id": 123,
    "name": "John",
    "email": "john@example.com",
    "status": "active",
    "lastLogin": "2024-01-01T10:00:00Z",
    "preferences": {"theme": "dark"}
}
""";

string requiredFields = """
{
    "id": 123,
    "name": "John",
    "status": "active"
}
""";

var result = comparer.SubsetMatch(requiredFields, apiResponse);
// result.IsMatch = true - all required fields present

Extended Value: Dynamic Content Handling

Beyond structural comparison, JsonComparer adds powerful features for handling dynamic content common in modern APIs:

Token Extraction for Dynamic Values

APIs generate IDs, timestamps, and tokens. Extract these for validation or subsequent use:

// API returns generated values
string apiResponse = """
{
    "userId": "usr_abc123",
    "sessionToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "email": "test@example.com",
    "status": "active"
}
""";

// Extract dynamic values while validating structure
string template = """
{
    "userId": "[[USER_ID]]",
    "sessionToken": "[[SESSION_TOKEN]]",
    "email": "test@example.com",
    "status": "active"
}
""";

var result = comparer.ExactMatch(template, apiResponse);
// Structure validated, dynamic values extracted:
// result.ExtractedValues["USER_ID"] = "usr_abc123"
// result.ExtractedValues["SESSION_TOKEN"] = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Discard Tokens for Presence Testing

Sometimes you only care that a field exists, not its specific value. Use [[]] (empty token) as a true discard token:

string apiResponse = """
{
    "userId": "usr_abc123",
    "internalId": "internal_xyz789",
    "debugInfo": {"requestId": "req_456", "processingTime": 150},
    "email": "test@example.com"
}
""";

// Test that fields exist without caring about their values
string template = """
{
    "userId": "[[USER_ID]]",
    "internalId": "[[]]",
    "debugInfo": "[[]]",
    "email": "test@example.com"
}
""";

var result = comparer.ExactMatch(template, apiResponse);
// Validates that internalId and debugInfo exist, but values are truly discarded
// Only USER_ID is captured: result.ExtractedValues["USER_ID"] = "usr_abc123"
// Empty tokens are not added to ExtractedValues at all

You can also use named discard tokens like [[_DISCARD_]] for readability:

string template = """
{
    "userId": "[[USER_ID]]",
    "internalId": "[[_DISCARD_]]",
    "debugInfo": "[[_DISCARD_]]",
    "email": "test@example.com"
}
""";

// [[_]] creates an entry: result.ExtractedValues["_DISCARD_"] = "internal_xyz789"
// [[]] creates no entry (true discard)

JsonElement Validation of Extracted Values

Extracted tokens are JsonElement objects, allowing rich type and value validation:

string apiResponse = """
{
    "userId": 12345,
    "score": 98.5,
    "isActive": true,
    "tags": ["premium", "verified"],
    "metadata": {
        "created": "2024-01-01T10:00:00Z",
        "source": "api"
    },
    "nullField": null
}
""";

string template = """
{
    "userId": "[[USER_ID]]",
    "score": "[[SCORE]]",
    "isActive": "[[ACTIVE]]",
    "tags": "[[TAGS]]",
    "metadata": "[[META]]",
    "nullField": "[[NULL_FIELD]]"
}
""";

var result = comparer.ExactMatch(template, apiResponse);

// Validate JSON types
Assert.Equal(JsonValueKind.Number, result.ExtractedValues["USER_ID"].ValueKind);
Assert.Equal(JsonValueKind.Number, result.ExtractedValues["SCORE"].ValueKind);
Assert.Equal(JsonValueKind.True, result.ExtractedValues["ACTIVE"].ValueKind);
Assert.Equal(JsonValueKind.Array, result.ExtractedValues["TAGS"].ValueKind);
Assert.Equal(JsonValueKind.Object, result.ExtractedValues["META"].ValueKind);
Assert.Equal(JsonValueKind.Null, result.ExtractedValues["NULL_FIELD"].ValueKind);

// Extract and validate specific values
Assert.Equal(12345, result.ExtractedValues["USER_ID"].GetInt32());
Assert.Equal(98.5, result.ExtractedValues["SCORE"].GetDouble());
Assert.True(result.ExtractedValues["ACTIVE"].GetBoolean());

// Work with arrays
var tagsArray = result.ExtractedValues["TAGS"].EnumerateArray().ToArray();
Assert.Equal(2, tagsArray.Length);
Assert.Equal("premium", tagsArray[0].GetString());
Assert.Equal("verified", tagsArray[1].GetString());

// Work with objects
var metadata = result.ExtractedValues["META"];
Assert.Equal("2024-01-01T10:00:00Z", metadata.GetProperty("created").GetString());
Assert.Equal("api", metadata.GetProperty("source").GetString());

Advanced JsonElement Validation Patterns

Use JsonElement properties for sophisticated validation:

string complexResponse = """
{
    "pagination": {
        "page": 1,
        "pageSize": 20,
        "totalItems": 150,
        "hasNext": true
    },
    "items": [
        {"id": 1, "name": "Item 1", "price": 29.99},
        {"id": 2, "name": "Item 2", "price": 15.50}
    ],
    "summary": {
        "averagePrice": 22.745,
        "categories": ["electronics", "books", "clothing"]
    }
}
""";

string template = """
{
    "pagination": "[[PAGINATION]]",
    "items": "[[ITEMS]]",
    "summary": "[[SUMMARY]]"
}
""";

var result = comparer.ExactMatch(template, complexResponse);

// Validate pagination object structure
var pagination = result.ExtractedValues["PAGINATION"];
Assert.True(pagination.TryGetProperty("page", out var pageElement));
Assert.True(pagination.TryGetProperty("totalItems", out var totalElement));
Assert.Equal(1, pageElement.GetInt32());
Assert.True(totalElement.GetInt32() > 0);

// Validate array contents
var items = result.ExtractedValues["ITEMS"];
Assert.True(items.GetArrayLength() > 0);

foreach (var item in items.EnumerateArray())
{
    Assert.True(item.TryGetProperty("id", out var idElement));
    Assert.True(item.TryGetProperty("price", out var priceElement));
    Assert.Equal(JsonValueKind.Number, idElement.ValueKind);
    Assert.Equal(JsonValueKind.Number, priceElement.ValueKind);
    Assert.True(priceElement.GetDouble() > 0);
}

// Validate nested array in object
var summary = result.ExtractedValues["SUMMARY"];
var categories = summary.GetProperty("categories");
Assert.Equal(JsonValueKind.Array, categories.ValueKind);
Assert.True(categories.GetArrayLength() >= 3);

// Validate all categories are strings
foreach (var category in categories.EnumerateArray())
{
    Assert.Equal(JsonValueKind.String, category.ValueKind);
    Assert.False(string.IsNullOrEmpty(category.GetString()));
}

Business Logic Validation with Extracted Values

Combine structural validation with business rule validation:

string orderResponse = """
{
    "orderId": "ord_12345",
    "customerId": "cust_67890",
    "items": [
        {"productId": "prod_1", "quantity": 2, "unitPrice": 25.00, "total": 50.00},
        {"productId": "prod_2", "quantity": 1, "unitPrice": 15.99, "total": 15.99}
    ],
    "subtotal": 65.99,
    "tax": 5.28,
    "total": 71.27,
    "status": "confirmed"
}
""";

string template = """
{
    "orderId": "[[ORDER_ID]]",
    "customerId": "[[CUSTOMER_ID]]",
    "items": "[[ITEMS]]",
    "subtotal": "[[SUBTOTAL]]",
    "tax": "[[TAX]]",
    "total": "[[TOTAL]]",
    "status": "confirmed"
}
""";

var result = comparer.ExactMatch(template, orderResponse);

// Extract values for business logic validation
var items = result.ExtractedValues["ITEMS"];
var subtotal = result.ExtractedValues["SUBTOTAL"].GetDouble();
var tax = result.ExtractedValues["TAX"].GetDouble();
var total = result.ExtractedValues["TOTAL"].GetDouble();

// Validate business rules
double calculatedSubtotal = 0;
foreach (var item in items.EnumerateArray())
{
    var quantity = item.GetProperty("quantity").GetInt32();
    var unitPrice = item.GetProperty("unitPrice").GetDouble();
    var itemTotal = item.GetProperty("total").GetDouble();

    // Validate item total calculation
    Assert.Equal(quantity * unitPrice, itemTotal, 2);
    calculatedSubtotal += itemTotal;
}

// Validate order totals
Assert.Equal(calculatedSubtotal, subtotal, 2);
Assert.Equal(subtotal + tax, total, 2);

// Validate tax rate (assuming 8% tax)
var expectedTax = Math.Round(subtotal * 0.08, 2);
Assert.Equal(expectedTax, tax, 2);

// Extract IDs for subsequent API calls
var orderId = result.ExtractedValues["ORDER_ID"].GetString();
var customerId = result.ExtractedValues["CUSTOMER_ID"].GetString();

Function-Based Validation

Validate generated values like GUIDs and timestamps without hardcoding specific values:

string eventPayload = """
{
    "eventId": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": "2024-01-01T10:00:00.000Z",
    "eventType": "UserCreated"
}
""";

string expectedPattern = """
{
    "eventId": "{{GUID()}}",
    "timestamp": "{{UTCNOW()}}",
    "eventType": "UserCreated"
}
""";

var result = comparer.ExactMatch(expectedPattern, eventPayload);
// Validates GUID format and timestamp format without exact value matching

Variable Substitution for Test Templates

Create reusable test templates with environment-specific values:

var testConfig = new Dictionary<string, object>
{
    ["BASE_URL"] = "https://api.staging.example.com",
    ["API_VERSION"] = "v2"
};

string expectedResponse = """
{
    "endpoint": "{{BASE_URL}}/{{API_VERSION}}/users",
    "version": "{{API_VERSION}}"
}
""";

string actualResponse = """
{
    "endpoint": "https://api.staging.example.com/v2/users",
    "version": "v2"
}
""";

var result = comparer.ExactMatch(expectedResponse, actualResponse, testConfig);
// Variables substituted before structural comparison

Why Use JsonComparer?

The Problem with Traditional JSON Testing

Traditional approaches fail with real-world API responses:

// ❌ Brittle - breaks with formatting differences, property order, or dynamic values
string expected = """{"id":"12345","createdAt":"2024-01-01T10:00:00Z"}""";
string actual = """  {  "createdAt": "2024-01-01T10:05:30Z",  "id": "67890"  }  """;
Assert.Equal(expected, actual); // FAILS due to formatting, order, and dynamic values

The JsonComparer Solution

// ✅ Robust - handles formatting, order, and dynamic values
string expected = """{"id": "[[USER_ID]]", "createdAt": "{{UTCNOW()}}"}""";
string actual = """  {  "createdAt": "2024-01-01T10:05:30Z",  "id": "67890"  }  """;

var result = comparer.ExactMatch(expected, actual);
// result.IsMatch = true - structure matches, dynamic values extracted

Core Features

  • Token Extraction: [[TOKEN_NAME]] - Extract dynamic values for later use
  • Function Execution: {{GUID()}}, {{NOW()}}, {{UTCNOW()}} - Validate generated values
  • Exact Matching: Strict structural and value comparison
  • Subset Matching: Verify required fields exist (ignores extra fields)
  • Variable Substitution: Template-based JSON with variable replacement
  • Custom Functions: Extend with your own validation functions
  • TimeProvider Support: Deterministic time testing
  • Detailed Error Reporting: Precise mismatch information

Installation

dotnet add package PQSoft.JsonComparer

Token Extraction Examples

Basic Token Extraction

Extract dynamic values from API responses for use in subsequent tests:

// API returns user with generated ID
string apiResponse = """
{
    "user": {
        "id": "usr_abc123",
        "email": "test@example.com",
        "status": "active"
    },
    "sessionToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
""";

string expectedStructure = """
{
    "user": {
        "id": "[[USER_ID]]",
        "email": "test@example.com",
        "status": "active"
    },
    "sessionToken": "[[SESSION_TOKEN]]"
}
""";

var comparer = new JsonComparer();
var result = comparer.ExactMatch(expectedStructure, apiResponse);

// Extract values for subsequent API calls
string userId = result.ExtractedValues["USER_ID"].GetString();     // "usr_abc123"
string token = result.ExtractedValues["SESSION_TOKEN"].GetString(); // "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Multi-Level Token Extraction

Handle complex nested structures with multiple dynamic values:

string orderResponse = """
{
    "order": {
        "id": "ord_789",
        "items": [
            {"productId": "prod_123", "quantity": 2, "price": 29.99},
            {"productId": "prod_456", "quantity": 1, "price": 15.50}
        ],
        "total": 75.48,
        "customer": {
            "id": "cust_999",
            "shippingAddress": {
                "id": "addr_555"
            }
        }
    },
    "paymentId": "pay_777"
}
""";

string template = """
{
    "order": {
        "id": "[[ORDER_ID]]",
        "items": [
            {"productId": "[[PRODUCT1_ID]]", "quantity": 2, "price": "[[PRICE1]]"},
            {"productId": "[[PRODUCT2_ID]]", "quantity": 1, "price": "[[PRICE2]]"}
        ],
        "total": "[[TOTAL]]",
        "customer": {
            "id": "[[CUSTOMER_ID]]",
            "shippingAddress": {
                "id": "[[ADDRESS_ID]]"
            }
        }
    },
    "paymentId": "[[PAYMENT_ID]]"
}
""";

var result = comparer.ExactMatch(template, orderResponse);
// Extracts: ORDER_ID, PRODUCT1_ID, PRICE1, PRODUCT2_ID, PRICE2, TOTAL, CUSTOMER_ID, ADDRESS_ID, PAYMENT_ID

Array Token Extraction

Extract values from JSON arrays:

string usersResponse = """
{
    "users": [
        {"id": "user_1", "name": "Alice", "role": "admin"},
        {"id": "user_2", "name": "Bob", "role": "user"},
        {"id": "user_3", "name": "Charlie", "role": "user"}
    ],
    "totalCount": 3
}
""";

string pattern = """
{
    "users": [
        {"id": "[[ADMIN_ID]]", "name": "Alice", "role": "admin"},
        {"id": "[[USER1_ID]]", "name": "Bob", "role": "user"},
        {"id": "[[USER2_ID]]", "name": "Charlie", "role": "user"}
    ],
    "totalCount": "[[TOTAL]]"
}
""";

var result = comparer.ExactMatch(pattern, usersResponse);
// Extract specific user IDs for role-based testing

Function Execution Examples

Time-Based Validation

Validate timestamps without hardcoding specific times:

string eventPayload = """
{
    "eventId": "550e8400-e29b-41d4-a716-446655440000",
    "eventType": "UserRegistered",
    "timestamp": "2024-01-01T10:00:00.000Z",
    "localTime": "2024-01-01T05:00:00.000-05:00",
    "data": {
        "userId": "user_123"
    }
}
""";

string expectedPattern = """
{
    "eventId": "{{GUID()}}",
    "eventType": "UserRegistered",
    "timestamp": "{{UTCNOW()}}",
    "localTime": "{{NOW()}}",
    "data": {
        "userId": "[[USER_ID]]"
    }
}
""";

var comparer = new JsonComparer();
var result = comparer.ExactMatch(expectedPattern, eventPayload);
// Validates GUID format, UTC timestamp, local timestamp, and extracts USER_ID

Deterministic Time Testing with TimeProvider

Control time for predictable tests:

// Set up controlled time
var fixedTime = new DateTimeOffset(2024, 6, 15, 14, 30, 0, TimeSpan.FromHours(-5));
var fakeTimeProvider = new FakeTimeProvider(fixedTime);

string actualResponse = """
{
    "processedAt": "2024-06-15T19:30:00.000Z",
    "localProcessedAt": "2024-06-15T14:30:00.000-05:00",
    "batchId": "batch_456"
}
""";

string expectedTemplate = """
{
    "processedAt": "{{UTCNOW()}}",
    "localProcessedAt": "{{NOW()}}",
    "batchId": "[[BATCH_ID]]"
}
""";

var comparer = new JsonComparer(fakeTimeProvider);
var result = comparer.ExactMatch(expectedTemplate, actualResponse);
// Uses controlled time for deterministic testing

Mixed Functions and Tokens

Combine function validation with token extraction:

string apiResponse = """
{
    "requestId": "req_abc123",
    "correlationId": "550e8400-e29b-41d4-a716-446655440000",
    "processedAt": "2024-01-01T10:00:00.000Z",
    "result": {
        "userId": "user_789",
        "status": "success"
    }
}
""";

string template = """
{
    "requestId": "[[REQUEST_ID]]",
    "correlationId": "{{GUID()}}",
    "processedAt": "{{UTCNOW()}}",
    "result": {
        "userId": "[[USER_ID]]",
        "status": "success"
    }
}
""";

var result = comparer.ExactMatch(template, apiResponse);
// Validates GUID and timestamp formats while extracting REQUEST_ID and USER_ID

Subset Matching Examples

API Contract Validation

Ensure required fields exist without caring about extra fields:

// API might return extra fields in the future
string apiResponse = """
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com",
    "status": "active",
    "createdAt": "2024-01-01T10:00:00Z",
    "lastLoginAt": "2024-01-15T08:30:00Z",
    "preferences": {
        "theme": "dark",
        "notifications": true
    },
    "metadata": {
        "source": "web",
        "version": "1.2.3"
    }
}
""";

// Only validate the fields we care about
string requiredFields = """
{
    "id": "[[USER_ID]]",
    "name": "John Doe",
    "email": "john@example.com",
    "status": "active"
}
""";

var result = comparer.SubsetMatch(requiredFields, apiResponse);
// Passes even with extra fields in response

Database Record Validation

Validate core fields while ignoring audit fields:

string dbRecord = """
{
    "id": 456,
    "productName": "Widget Pro",
    "price": 29.99,
    "category": "electronics",
    "inStock": true,
    "createdAt": "2024-01-01T10:00:00Z",
    "updatedAt": "2024-01-15T14:30:00Z",
    "createdBy": "admin",
    "version": 3
}
""";

string coreFields = """
{
    "productName": "Widget Pro",
    "price": "[[PRICE]]",
    "category": "electronics",
    "inStock": true
}
""";

var result = comparer.SubsetMatch(coreFields, dbRecord);
// Ignores audit fields, extracts price for further validation

Microservices Integration Testing

Validate service contracts while allowing implementation flexibility:

string serviceResponse = """
{
    "status": "success",
    "data": {
        "resourceId": "res_123",
        "type": "document",
        "permissions": ["read", "write"],
        "owner": {
            "id": "user_456",
            "name": "Alice"
        }
    },
    "metadata": {
        "processingTime": 150,
        "server": "api-01",
        "requestId": "req_789"
    },
    "links": {
        "self": "/api/resources/res_123",
        "owner": "/api/users/user_456"
    }
}
""";

string contractRequirements = """
{
    "status": "success",
    "data": {
        "resourceId": "[[RESOURCE_ID]]",
        "type": "document",
        "owner": {
            "id": "[[OWNER_ID]]"
        }
    }
}
""";

var result = comparer.SubsetMatch(contractRequirements, serviceResponse);
// Validates contract compliance while allowing extra implementation details

Variable Substitution Examples

Environment-Specific Testing

Use variables for different environments:

var testConfig = new Dictionary<string, object>
{
    ["BASE_URL"] = "https://api.staging.example.com",
    ["API_VERSION"] = "v2",
    ["TENANT_ID"] = "tenant_staging_123"
};

string expectedResponse = """
{
    "apiEndpoint": "{{BASE_URL}}/{{API_VERSION}}/tenants/{{TENANT_ID}}",
    "version": "{{API_VERSION}}",
    "tenantId": "{{TENANT_ID}}",
    "environment": "staging"
}
""";

string actualResponse = """
{
    "apiEndpoint": "https://api.staging.example.com/v2/tenants/tenant_staging_123",
    "version": "v2",
    "tenantId": "tenant_staging_123",
    "environment": "staging"
}
""";

var result = comparer.ExactMatch(expectedResponse, actualResponse, testConfig);
// Variables are substituted before comparison

Test Data Templates

Create reusable test templates:

var userTestData = new Dictionary<string, object>
{
    ["USER_EMAIL"] = "test.user@example.com",
    ["USER_ROLE"] = "premium",
    ["SUBSCRIPTION_TIER"] = "gold",
    ["MAX_API_CALLS"] = 10000
};

string userProfileTemplate = """
{
    "profile": {
        "email": "{{USER_EMAIL}}",
        "role": "{{USER_ROLE}}",
        "subscription": {
            "tier": "{{SUBSCRIPTION_TIER}}",
            "limits": {
                "apiCalls": {{MAX_API_CALLS}}
            }
        }
    },
    "userId": "[[GENERATED_USER_ID]]",
    "createdAt": "{{UTCNOW()}}"
}
""";

// Use template with different test data sets
var result = comparer.ExactMatch(userProfileTemplate, actualUserResponse, userTestData);

Custom Functions

Register Domain-Specific Functions

Add custom validation functions for your domain:

// Register custom functions
JsonComparer.RegisterFunction("ORDER_NUMBER", () => $"ORD-{DateTime.Now:yyyyMMdd}-{Random.Shared.Next(1000, 9999)}");
JsonComparer.RegisterFunction("SKU_CODE", () => $"SKU-{Guid.NewGuid().ToString("N")[..8].ToUpper()}");
JsonComparer.RegisterFunction("PRICE_FORMAT", () => $"{Random.Shared.NextDouble() * 100:F2}");

string orderTemplate = """
{
    "orderNumber": "{{ORDER_NUMBER()}}",
    "items": [
        {
            "sku": "{{SKU_CODE()}}",
            "price": "{{PRICE_FORMAT()}}"
        }
    ],
    "customerId": "[[CUSTOMER_ID]]"
}
""";

string actualOrder = """
{
    "orderNumber": "ORD-20240101-1234",
    "items": [
        {
            "sku": "SKU-A1B2C3D4",
            "price": "29.99"
        }
    ],
    "customerId": "cust_789"
}
""";

var result = comparer.ExactMatch(orderTemplate, actualOrder);
// Custom functions validate format while extracting CUSTOMER_ID

Parameterized Custom Functions

Create functions that accept parameters:

JsonComparer.RegisterFunction("DATE_FORMAT", (format) => DateTime.Now.ToString(format));
JsonComparer.RegisterFunction("RANDOM_STRING", (length) =>
    new string(Enumerable.Range(0, int.Parse(length))
        .Select(_ => (char)Random.Shared.Next('A', 'Z' + 1))
        .ToArray()));

string template = """
{
    "reportDate": "{{DATE_FORMAT('yyyy-MM-dd')}}",
    "reportId": "{{RANDOM_STRING('8')}}",
    "data": "[[REPORT_DATA]]"
}
""";

Error Handling and Debugging

Detailed Mismatch Information

Get precise information about what doesn't match:

string expected = """
{
    "user": {
        "name": "John",
        "age": 30,
        "preferences": {
            "theme": "dark"
        }
    },
    "status": "active"
}
""";

string actual = """
{
    "user": {
        "name": "Jane",
        "age": 25,
        "preferences": {
            "theme": "light"
        }
    },
    "status": "inactive"
}
""";

var result = comparer.ExactMatch(expected, actual);

if (!result.IsMatch)
{
    foreach (var mismatch in result.Mismatches)
    {
        Console.WriteLine($"Path: {mismatch.Path}");
        Console.WriteLine($"Expected: {mismatch.Expected}");
        Console.WriteLine($"Actual: {mismatch.Actual}");
        Console.WriteLine($"Type: {mismatch.Type}");
        Console.WriteLine("---");
    }
}

// Output:
// Path: $.user.name
// Expected: John
// Actual: Jane
// Type: ValueMismatch
// ---
// Path: $.user.age
// Expected: 30
// Actual: 25
// Type: ValueMismatch
// ---
// Path: $.user.preferences.theme
// Expected: dark
// Actual: light
// Type: ValueMismatch
// ---
// Path: $.status
// Expected: active
// Actual: inactive
// Type: ValueMismatch

Validation with Exception Details

Handle validation errors gracefully:

try
{
    string malformedJson = """{ "name": "John", "age": }"""; // Invalid JSON
    var result = comparer.ExactMatch(expectedJson, malformedJson);
}
catch (JsonException ex)
{
    Console.WriteLine($"JSON parsing error: {ex.Message}");
    // Handle malformed JSON appropriately
}

Real-World Use Cases

1. API Integration Testing

// Test user registration endpoint
var registrationData = new { email = "test@example.com", password = "secure123" };
var response = await httpClient.PostAsJsonAsync("/api/register", registrationData);
var responseJson = await response.Content.ReadAsStringAsync();

var expectedStructure = """
{
    "user": {
        "id": "[[USER_ID]]",
        "email": "test@example.com",
        "createdAt": "{{UTCNOW()}}",
        "status": "pending_verification"
    },
    "verificationToken": "[[VERIFICATION_TOKEN]]"
}
""";

var result = comparer.ExactMatch(expectedStructure, responseJson);
Assert.True(result.IsMatch);

// Use extracted values in follow-up tests
var userId = result.ExtractedValues["USER_ID"].GetString();
var verificationToken = result.ExtractedValues["VERIFICATION_TOKEN"].GetString();

2. Event-Driven Architecture Testing

// Validate published events
string publishedEvent = await eventStore.GetLastEventAsync("UserRegistered");

var expectedEventStructure = """
{
    "eventId": "{{GUID()}}",
    "eventType": "UserRegistered",
    "aggregateId": "[[USER_ID]]",
    "timestamp": "{{UTCNOW()}}",
    "version": 1,
    "data": {
        "email": "test@example.com",
        "registrationSource": "web"
    }
}
""";

var result = comparer.ExactMatch(expectedEventStructure, publishedEvent);
Assert.True(result.IsMatch);

3. Database Integration Testing

// Validate database state after operation
var dbUser = await userRepository.GetByEmailAsync("test@example.com");
var userJson = JsonSerializer.Serialize(dbUser);

var expectedDbState = """
{
    "id": "[[DB_USER_ID]]",
    "email": "test@example.com",
    "passwordHash": "[[PASSWORD_HASH]]",
    "createdAt": "{{UTCNOW()}}",
    "updatedAt": "{{UTCNOW()}}",
    "isActive": true,
    "emailVerified": false
}
""";

var result = comparer.ExactMatch(expectedDbState, userJson);
Assert.True(result.IsMatch);

4. Configuration Validation

// Validate application configuration
string configJson = await configService.GetConfigurationAsync();

var requiredConfigStructure = """
{
    "database": {
        "connectionString": "[[DB_CONNECTION]]",
        "timeout": "[[DB_TIMEOUT]]"
    },
    "api": {
        "baseUrl": "[[API_BASE_URL]]",
        "version": "v1"
    },
    "features": {
        "enableNewFeature": "[[FEATURE_FLAG]]"
    }
}
""";

var result = comparer.SubsetMatch(requiredConfigStructure, configJson);
Assert.True(result.IsMatch);

// Validate extracted configuration values
Assert.NotEmpty(result.ExtractedValues["DB_CONNECTION"].GetString());
Assert.True(int.Parse(result.ExtractedValues["DB_TIMEOUT"].GetString()) > 0);

Advanced Patterns

Chain Multiple Comparisons

// Multi-step API testing workflow
var createResult = comparer.ExactMatch(createUserTemplate, createResponse);
var userId = createResult.ExtractedValues["USER_ID"].GetString();

var getUserResponse = await httpClient.GetAsync($"/api/users/{userId}");
var getUserJson = await getUserResponse.Content.ReadAsStringAsync();

var getUserTemplate = """
{
    "id": "[[SAME_USER_ID]]",
    "email": "test@example.com",
    "status": "active",
    "profile": {
        "createdAt": "{{UTCNOW()}}"
    }
}
""";

var getResult = comparer.ExactMatch(getUserTemplate, getUserJson);
Assert.Equal(userId, getResult.ExtractedValues["SAME_USER_ID"].GetString());

Conditional Validation

// Different validation based on response type
var baseTemplate = """
{
    "status": "[[STATUS]]",
    "timestamp": "{{UTCNOW()}}"
}
""";

var result = comparer.SubsetMatch(baseTemplate, apiResponse);
var status = result.ExtractedValues["STATUS"].GetString();

if (status == "success")
{
    var successTemplate = """
    {
        "status": "success",
        "data": {
            "id": "[[RESOURCE_ID]]"
        }
    }
    """;
    var successResult = comparer.SubsetMatch(successTemplate, apiResponse);
    Assert.True(successResult.IsMatch);
}
else if (status == "error")
{
    var errorTemplate = """
    {
        "status": "error",
        "error": {
            "code": "[[ERROR_CODE]]",
            "message": "[[ERROR_MESSAGE]]"
        }
    }
    """;
    var errorResult = comparer.SubsetMatch(errorTemplate, apiResponse);
    Assert.True(errorResult.IsMatch);
}

Performance Considerations

JsonComparer is optimized for testing scenarios:

  • Efficient JSON parsing using System.Text.Json
  • Minimal memory allocation for token extraction
  • Fast path for exact matches without tokens or functions
  • Lazy evaluation of functions only when needed

For high-volume scenarios, consider:

  • Reusing JsonComparer instances
  • Pre-compiling templates with variables
  • Using subset matching when full validation isn't needed

Dependencies

  • System.Text.Json (built-in .NET)
  • No external dependencies for core functionality
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on PQSoft.JsonComparer:

Package Downloads
PQSoft.JsonComparer.AwesomeAssertions

AwesomeAssertions extensions for JSON comparison that integrate with PQSoft.JsonComparer. Provides fluent assertion methods for JSON validation with token extraction and subset matching.

PQSoft.ReqNRoll

Reqnroll step definitions for API testing with PQSoft.HttpFile and PQSoft.JsonComparer

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.0-beta.4 185 10/22/2025
1.2.0-beta.3 173 10/22/2025
1.2.0-beta.2 167 10/22/2025
1.2.0-beta.1 163 10/22/2025
1.1.0-beta.3 206 9/20/2025
1.1.0-beta.2 215 9/19/2025
1.1.0-beta.1 219 9/19/2025
1.0.0-beta.6 287 9/19/2025
1.0.0-beta.5 275 9/19/2025
1.0.0-beta.4 280 9/19/2025
1.0.0-beta.3 279 9/19/2025
1.0.0-beta.2 274 9/19/2025
1.0.0-beta.1 298 9/19/2025
1.0.0-alpha9 290 9/18/2025
1.0.0-alpha8 298 9/18/2025
1.0.0-alpha7 296 9/17/2025
1.0.0-alpha5 302 9/16/2025
1.0.0-alpha4 306 9/16/2025
1.0.0-alpha11 301 9/19/2025
1.0.0-alpha10 297 9/18/2025
Loading failed