AspNetCore.Simple.MsTest.Sdk
9.5.13
See the version list below for details.
dotnet add package AspNetCore.Simple.MsTest.Sdk --version 9.5.13
NuGet\Install-Package AspNetCore.Simple.MsTest.Sdk -Version 9.5.13
<PackageReference Include="AspNetCore.Simple.MsTest.Sdk" Version="9.5.13" />
<PackageVersion Include="AspNetCore.Simple.MsTest.Sdk" Version="9.5.13" />
<PackageReference Include="AspNetCore.Simple.MsTest.Sdk" />
paket add AspNetCore.Simple.MsTest.Sdk --version 9.5.13
#r "nuget: AspNetCore.Simple.MsTest.Sdk, 9.5.13"
#:package AspNetCore.Simple.MsTest.Sdk@9.5.13
#addin nuget:?package=AspNetCore.Simple.MsTest.Sdk&version=9.5.13
#tool nuget:?package=AspNetCore.Simple.MsTest.Sdk&version=9.5.13
AspNetCore.Simple.MsTest.Sdk
API snapshot testing so productive it feels like cheating.
Add a JSON file. A test appears. When it fails, you get the exact diff, full HTTP context, and a ready-to-runcurl.AI-friendly assertions that let your AI assistant fix your tests.
Every failure includes context (because), guidance (fix), and structured output. Human-readable. AI-parseable.
[TestMethod]
[DynamicRequestLocator]
public Task Should_Create_User(string useCase)
{
return Client.AssertPostAsync<UserResponse>("api/v1/users",
useCase,
useCase);
}
The same test with the fluent API — it reads like the endpoint contract it verifies:
// Alpha version only
[TestMethod]
[DynamicRequestLocator]
public Task Should_Create_User(string useCase)
{
return Client.AssertPost("api/v1/users")
.AcceptsFromEmbeddedJson(useCase)
.Produces<UserResponse>(HttpStatusCode.OK)
.ExpectedResponseFromEmbeddedJson(useCase)
.ExecuteAsync();
}
⚠️ The fluent entry points (
AssertPost,AssertGet, …) arepubliconly in prerelease builds (FLUENT_ALPHA); in stable packages they stayinternal. See Fluent Assert API (Alpha).
Quick Start
Install
dotnet add package AspNetCore.Simple.MsTest.Sdk
Minimal setup
Use the SDK's ApiTestBase<TStartup> and there is nothing to wire up — it registers and initializes
everything the assert extensions need:
[TestClass]
public abstract class ApiTestBase
{
private static ApiTestBase<Program> _apiTestBase = null!;
[AssemblyInitialize]
public static void AssemblyInitialize(TestContext _)
{
// Use Program or Startup as entry point for proper WebApplicationFactory support
// - Program: for minimal API / top-level statements (Program.cs)
// - Startup: for traditional Startup.cs class
_apiTestBase = new ApiTestBase<Program>("Development",
(services,
configuration) =>
{
// Only your own overrides / test doubles go here.
// Nothing SDK-related is required.
});
Client = _apiTestBase.CreateClient();
}
protected static HttpClient Client { get; private set; } = null!;
[AssemblyCleanup]
public static void AssemblyCleanup()
{
_apiTestBase.Dispose();
Client.Dispose();
}
}
That's it. ApiTestBase<TStartup> performs both required steps internally:
services.AddAssertableHttpClient(configuration)— registersIAssertableHttpClient, the endpoint registry used for validation, the diff engine and the failure reporters.HttpClientAssertExtensions.Setup(serviceProvider)— hands those resolved services to the staticAssert…Asyncextension methods.
Bringing your own host? Then do these two steps yourself
If you don't use ApiTestBase<TStartup> — e.g. you have your own WebApplicationFactory<T>, a custom
fixture, or a hand-rolled host — the SDK cannot hook itself in. You have to make both calls explicitly,
exactly once, in [AssemblyInitialize]:
[TestClass]
public abstract class ApiTestBase
{
private static WebApplicationFactory<Program> _factory = null!;
[AssemblyInitialize]
public static void AssemblyInitialize(TestContext _)
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices((context,
services) =>
{
// 1. REQUIRED: endpoint validation + assertable HTTP client features
services.AddAssertableHttpClient(context.Configuration);
});
});
Client = _factory.CreateClient();
// 2. REQUIRED: makes all HttpClientAssertExtensions 100% functional.
// Must run *after* the host is built, and must use the *real* provider of the running host.
HttpClientAssertExtensions.Setup(_factory.Services);
}
protected static HttpClient Client { get; private set; } = null!;
[AssemblyCleanup]
public static void AssemblyCleanup()
{
_factory.Dispose();
Client.Dispose();
}
}
Miss either step and the first assert call tells you so instead of failing cryptically:
⚠️ MISSING REGISTRATION
The AssertableHttpClient requires endpoint registration to validate HTTP calls.
Please ensure the following registrations exist in your test setup:
1. services.AddAssertableHttpClient(configuration);
2. HttpClientAssertExtensions.Setup(_apiTestBase.Services);
First test
[TestClass]
public class UserTests : ApiTestBase
{
[TestMethod]
public Task Should_Create_User()
{
return Client.AssertPostAsync<UserResponse>(
"api/v1/users",
"CreateUser.json",
"CreateUser.json");
}
}
Fluent equivalent:
// Alpha version only
[TestClass]
public class UserTests : ApiTestBase
{
[TestMethod]
public Task Should_Create_User()
{
return Client.AssertPost("api/v1/users")
.AcceptsFromEmbeddedJson("CreateUser.json")
.Produces<UserResponse>(HttpStatusCode.OK)
.ExpectedResponseFromEmbeddedJson("CreateUser.json")
.ExecuteAsync();
}
}
Both styles run the same pipeline and produce the same failure output. The fluent chain additionally
makes the expected status code explicit and is guarded by an analyzer: forgetting ExecuteAsync()
is a build error (MSTESTSDK001), not a silently passing test.
Add the JSON snapshot files
Use embedded JSON files for request and expected response.
CreateUser.json request:
{
"Id": 1,
"Name": "Son",
"FirstName": "Goku",
"Age": 99,
"Emails": [
{
"EmailAddress": "alf@gmx.de",
"Type": "GMX"
},
{
"EmailAddress": "abc@hotmail.de",
"Type": "Microsoft"
}
]
}
CreateUser.json response snapshot:
{
"Content": {
"Headers": [
{
"Key": "Content-Type",
"Value": [ "application/json; charset=utf-8" ]
}
],
"Value": {
"Id": 1,
"Name": "Son",
"FirstName": "Goku",
"Age": 99,
"Emails": []
}
},
"StatusCode": "OK",
"Headers": [],
"TrailingHeaders": [],
"IsSuccessStatusCode": true
}
Result
Run the test and you get:
- full-response validation
- structured diffs on mismatch
- HTTP context in the failure output
- generated
curlfor instant reproduction
What you get
- HTTP QUERY support (RFC 10008) - Complex queries with request body, GET semantics
- Full HTTP response snapshots: status, headers, body, trailing headers
- Precise structured diffs with deep
MemberPathpaths - Context-specific error headers (Snapshot Mismatch, Schema Mismatch, Status Code, etc.)
- Clickable file links in error output - jump directly to failing test line in your IDE
- Fully qualified class names - see complete namespace path in test information
- Ready-to-run
curloutput on failures - Convention-based test discovery with
DynamicRequestLocator - Snapshot generation from live traffic
- Snapshot auto-update and ignore strategies
Assert.That.*AI-friendly assertions - mandatorybecause/fixcontext on every assertion- Roslyn analyzer + code fix shipped in the package - a dangling fluent chain without
ExecuteAsync()fails the build (MSTESTSDK001) instead of passing silently - Drastically less boilerplate than traditional API tests
Supported HTTP Methods & Content Types
Complete feature matrix showing what's supported out of the box:
HTTP Methods
| Method | With Body | Success Response | Error Response |
|---|---|---|---|
| GET | ❌ | ✅ AssertGetAsync<T>() |
✅ AssertGetAsErrorAsync<T>() |
| QUERY RFC 10008 | ✅ | ✅ AssertQueryAsync<T>() |
✅ AssertQueryAsErrorAsync<T>() |
| POST | ✅ | ✅ AssertPostAsync<T>() |
✅ AssertPostAsErrorAsync<T>() |
| PUT | ✅ | ✅ AssertPutAsync<T>() |
✅ AssertPutAsErrorAsync<T>() |
| PATCH | ✅ | ✅ AssertPatchAsync<T>() |
✅ AssertPatchAsErrorAsync<T>() |
| DELETE | ❌ | ✅ AssertDeleteAsync<T>() |
✅ AssertDeleteAsErrorAsync<T>() |
| OPTIONS | ❌ | ✅ AssertOptionsAsync() |
❌ |
Note: POST, PUT, PATCH, DELETE also support NoContent (204) variants without <T> generic parameter.
Content Types
| Content Type | Request | Response | Snapshot Format | Status |
|---|---|---|---|---|
| application/json | ✅ | ✅ | .json files |
✅ Full support |
| application/xml | ❌ | ❌ | N/A | ⏳ Planned |
| multipart/form-data | ❌ | N/A | N/A | ⏳ Planned |
| application/x-www-form-urlencoded | ❌ | N/A | N/A | ⏳ Planned |
| text/plain | ✅ | ✅ | .txt files |
✅ String comparison |
Features
| Feature | Support | Notes |
|---|---|---|
| Request body validation | ✅ | JSON snapshots |
| Response body validation | ✅ | Deep object comparison |
| Status code validation | ✅ | Expected vs actual |
| Header validation | ✅ | Full HTTP response snapshots |
| Query parameters | ✅ | URL parameters + parameter replacement |
| Dynamic parameters | ✅ | $placeholder$ replacement in JSON |
| File upload | ❌ | Multipart not yet supported |
| Binary responses | ❌ | Text/JSON only |
| Streaming | ❌ | Snapshot-based only |
| WebSockets | ❌ | HTTP only |
Legend:
- ✅ = Fully supported
- ⏳ = Planned for future releases
- ❌ = Not supported
Current focus: JSON-based REST APIs with full snapshot testing support for all standard HTTP methods including the new QUERY method.
Example: Complete CRUD workflow with QUERY
[TestClass]
public class UserApiTests : ApiTestBase
{
[TestMethod]
public async Task Complete_User_Lifecycle()
{
// CREATE - POST with response
var created = await Client.AssertPostAsync<User>(
"api/v1/users",
"CreateUser.json",
"CreatedUser.json");
// READ - GET single resource
await Client.AssertGetAsync<User>(
$"api/v1/users/{created.Id}",
"UserDetails.json");
// QUERY - Complex search with body (new RFC 10008 method!)
await Client.AssertQueryAsync<SearchResults>(
"api/v1/users/search",
"SearchRequest.json",
"SearchResults.json");
// UPDATE - PUT with response
await Client.AssertPutAsync<User>(
$"api/v1/users/{created.Id}",
"UpdateUser.json",
"UpdatedUser.json");
// PARTIAL UPDATE - PATCH with response
await Client.AssertPatchAsync<User>(
$"api/v1/users/{created.Id}",
"PatchUser.json",
"PatchedUser.json");
// DELETE - with NoContent (204)
await Client.AssertDeleteAsync(
$"api/v1/users/{created.Id}");
}
}
All methods support:
- ✅ Full response snapshots
- ✅ Error scenarios with
AsErrorAsyncvariants - ✅ Dynamic parameter replacement
- ✅ Ignore strategies for dynamic values
- ✅ Endpoint validation against
[ProducesResponseType]
Why this feels different
Most API testing tools make you choose between speed, coverage, and debuggability.
This SDK does not.
It is built around a simple idea:
- One snapshot validates the whole HTTP response, not just the body
- One failure tells you exactly what changed, down to
content.value.emails[1].type - One pasted
curlreproduces the problem immediately - One added JSON file creates a new test case automatically
That combination changes how API testing feels in practice. Less plumbing. More coverage. Faster debugging.
File conventions and folder structure
Use file names, not full resource paths
Prefer this:
"NewUser.json"
Not this:
"Users.V1.Payloads.NewUser.json"
Context-aware disambiguation
If multiple files with the same name exist in different folders, the SDK prefers the file in the same namespace as your test.
Example structure:
Api/
├─ Persons/
│ └─ Requests/SonGoku.json ← Test in Persons namespace uses this
├─ Errors/
│ └─ Requests/SonGoku.json
└─ NativeTypes/
└─ Requests/SonGoku.json
When you reference "Requests.SonGoku.json" from a test in the Api.Persons namespace, the SDK automatically picks
Api.Persons.Requests.SonGoku.json.
If needed, you can be more specific:
"Api.Persons.Requests.SonGoku.json" // Fully qualified
"Persons.Requests.SonGoku.json" // Partial namespace
The SDK uses segment-based matching to avoid false positives. "Requests.SonGoku.json" will not match
"ErrorRequests.SonGoku.json" because the dot boundary matters.
This means you get:
- short, readable file references in tests
- automatic disambiguation by context
- explicit paths when you need them
- predictable resolution behavior
Recommended structure
Api
└─ Users
└─ V1
└─ Create
└─ Status_200_Ok
├─ Requests
│ ├─ ValidUser.json
│ ├─ AdminUser.json
│ └─ GuestUser.json
├─ Responses
│ ├─ ValidUser.json
│ ├─ AdminUser.json
│ └─ GuestUser.json
└─ CreateUser_Status_200_OK_Test.cs
Why this structure works well
Requestscontains input payloadsResponsescontains expected snapshots- namespace mirrors folder structure
DynamicRequestLocatorcan discover request files automatically- adding scenarios stays simple and predictable
Example:
namespace Api.Users.V1.Create.Status_200_Ok;
[TestClass]
public class CreateUser_Status_200_OK_Test : ApiTestBase
{
[DataTestMethod]
[DynamicRequestLocator]
public Task Should_Create_User(string requestFileName)
{
return Client.AssertPostAsync<UserResponse>(
"api/v1/users",
requestFileName,
requestFileName);
}
}
Add a JSON file. A new test appears.
What a failure looks like
The SDK provides context-specific error outputs that make debugging fast and intuitive. Each failure type has a dedicated format with actionable information.
All Failure Types at a Glance
| Icon | Failure Type | When It Occurs | What It Means |
|---|---|---|---|
| 📸 | SNAPSHOT MISMATCH | JSON values differ | Business logic produces different values |
| 📋 | SCHEMA MISMATCH | Structure differs | API contract changed (breaking change) |
| 🚫 | UNEXPECTED STATUS CODE | Wrong HTTP status | Status code doesn't match expectation |
| 📄 | CONTENT TYPE MISMATCH | Wrong Content-Type | Response is not JSON (HTML, XML, etc.) |
| ❌ | ASSERT METHOD MISMATCH | Wrong assertion type | Using success assert with error status (or vice versa) |
| ❌ | HTTP RESPONSE TYPE MISMATCH | Wrong response type | Test type doesn't match endpoint contract |
All errors follow the same structure: Header → Failure Details → Test Info → HTTP Context → Problem Details → Suggested Fix → Curl Command
Note: The File field in Test Information contains a clickable file:// URI that works in most IDEs (Rider, VS
Code, Visual Studio). Click it to jump directly to the failing test line.
Snapshot Mismatch (Value Differences)
When JSON values differ from the expected snapshot:
══════════════════════════════════════════════════════════════
📸 SNAPSHOT MISMATCH
══════════════════════════════════════════════════════════════
⚠️ Failure Details
──────────────────────────────────────────────────────────────
JSON values differ from the expected snapshot.
All properties exist but have different values.
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Be_Able_To_Post_A_Person_Object
Line : 65
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:65
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : POST
Url : http://localhost/api/v1/persons
Status : 201 Created
Body : {"id":1,"name":"Son","firstName":"Goku","age":42,"emails":[]}
Response : NewPerson.json
🔍 Differences (Count 1)
──────────────────────────────────────────────────────────────
┌────────────────────┬────────────────┬───────────────┬─────────────────┐
│ MemberPath │ NewPerson.json │ CurrentResult │ MismatchType │
├────────────────────┼────────────────┼───────────────┼─────────────────┤
│ content.value.name │ Son Test │ Son │ ValueDifference │
└────────────────────┴────────────────┴───────────────┴─────────────────┘
📄 Expected Snapshot
──────────────────────────────────────────────────────────────
{"content":{"headers":[...],"value":{"id":1,"name":"Son Test","firstName":"Goku",...}}}
📄 Current Result
──────────────────────────────────────────────────────────────
{"content":{"headers":[...],"value":{"id":1,"name":"Son","firstName":"Goku",...}}}
🔁 Reproduce Locally
──────────────────────────────────────────────────────────────
curl \
--location \
--request POST 'http://localhost/api/v1/persons' \
--header 'Content-Type: application/json' \
--data-raw '{"id":1,"name":"Son","firstName":"Goku","age":42,"emails":[]}'
══════════════════════════════════════════════════════════════
Schema Mismatch (Structural Differences)
When the response structure doesn't match (missing properties, type mismatches):
══════════════════════════════════════════════════════════════
📋 SCHEMA MISMATCH
══════════════════════════════════════════════════════════════
⚠️ Failure Details
──────────────────────────────────────────────────────────────
Structure doesn't match expected type schema.
Properties missing, extra properties, or type mismatches detected.
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Get_Person_By_Id
Line : 42
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:42
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : GET
Url : http://localhost/api/v1/persons/1
Status : 200 OK
🔍 Differences (Count 2)
──────────────────────────────────────────────────────────────
┌──────────────────────┬──────────────────┬───────────────┬────────────────┐
│ MemberPath │ Expected │ Current │ MismatchType │
├──────────────────────┼──────────────────┼───────────────┼────────────────┤
│ content.value.emails │ [email array] │ null │ MissingInFirst │
│ content.value.age │ 42 │ null │ MissingInFirst │
└──────────────────────┴──────────────────┴───────────────┴────────────────┘
🔁 Reproduce Locally
──────────────────────────────────────────────────────────────
curl \
--location \
--request GET 'http://localhost/api/v1/persons/1'
══════════════════════════════════════════════════════════════
Assert Method Mismatch
When using success assertion (AssertPostAsync) with error status code:
══════════════════════════════════════════════════════════════
❌ ASSERT METHOD MISMATCH - SUCCESS EXPECTED
══════════════════════════════════════════════════════════════
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Create_Person
Line : 88
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:88
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : POST
Url : http://localhost/api/v1/persons
Status : Test Type Mismatch
⚠️ Problem
──────────────────────────────────────────────────────────────
The test is declared as a SUCCESS test (AssertPostAsync, AssertGetAsync, etc.)
but the expected response has status code 500 (InternalServerError) which is an ERROR status.
📊 Details
──────────────────────────────────────────────────────────────
Test Type : Success (expects 2xx)
Expected Status : 500 (InternalServerError)
Status Range : Error (4xx/5xx)
✅ Suggested Fix
──────────────────────────────────────────────────────────────
Option 1: Use error assertion method instead
- Use AssertPostAsErrorAsync() or similar error assertion method
Option 2: Update expected response status code
- Change the expected response to have a success status code (200, 201, etc.)
══════════════════════════════════════════════════════════════
Unexpected Status Code
When the HTTP status code doesn't match expectations:
══════════════════════════════════════════════════════════════
🚫 UNEXPECTED STATUS CODE
══════════════════════════════════════════════════════════════
⚠️ Failure Details
──────────────────────────────────────────────────────────────
Expected : 200 (Success)
Actual : 400 (Bad Request)
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Create_Person
Line : 65
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:65
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : POST
Url : http://localhost/api/v1/persons
Status : 400 Bad Request
🔁 Reproduce Locally
──────────────────────────────────────────────────────────────
curl \
--location \
--request POST 'http://localhost/api/v1/persons' \
--header 'Content-Type: application/json' \
--data-raw '{"name":"Invalid"}'
══════════════════════════════════════════════════════════════
Why This Matters
You immediately see:
- Failure type: Snapshot mismatch vs Schema mismatch vs Assert method issue
- Exact location:
content.value.namewith deep path precision - Expected vs actual: Side-by-side comparison
- HTTP context: Method, URL, status code, request body
- Reproduction command: Ready-to-run
curl - Suggested fixes: Actionable guidance
That is a completely different debugging experience from:
Assert.AreEqual("Son", response.Name); // ❌ No context, no curl, no path
This SDK does not just tell you that something failed. It tells you what kind of failure, where, **what changed **, under which HTTP call, and how to replay it now.
Response Type Mismatch
When test's response type doesn't match endpoint contract:
══════════════════════════════════════════════════════════════
❌ HTTP RESPONSE TYPE MISMATCH
══════════════════════════════════════════════════════════════
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Create_Person
Line : 65
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:65
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : POST
Url : http://localhost/api/v1/persons
Status : Type Mismatch
Source : MinimalApi.Api.Persons.V1.CreatePersonEndpoint
🔍 Type Validation
──────────────────────────────────────────────────────────────
┌─────────────┬────────────────────────┬────────────────────┬───────┐
│ Status Code │ Endpoint Response Type │ Declared Test Type │ Match │
├─────────────┼────────────────────────┼────────────────────┼───────┤
│ 201 │ Person │ UnknownResponse │ ✗ │
└─────────────┴────────────────────────┴────────────────────┴───────┘
The test is a success (2xx) test and declares response type 'UnknownResponse',
but none of the endpoint's success (2xx) status codes return this type.
Endpoint defines: 201 → Person
📝 Assert Call
──────────────────────────────────────────────────────────────
return Client.AssertPostAsync<UnknownResponse>("api/v1/persons",
new Person(1, "Son", "Goku",
42, ImmutableList<Email>.Empty),
"NewPerson.json");
✅ Suggested Fix
──────────────────────────────────────────────────────────────
return Client.AssertPostAsync<Person>("api/v1/persons",
new Person(1, "Son", "Goku",
42, ImmutableList<Email>.Empty),
"NewPerson.json");
🔁 Reproduce Locally
──────────────────────────────────────────────────────────────
curl \
--location \
--request POST 'http://localhost/api/v1/persons' \
--header 'Content-Type: application/json' \
--data-raw '{"id":1,"name":"Son","firstName":"Goku","age":42,"emails":[]}'
══════════════════════════════════════════════════════════════
Smart endpoint validation with fallback
The SDK validates that your test's response type matches the endpoint's contract using a three-tier strategy.
Tier 1: [ProducesResponseType] attributes
When your endpoint declares explicit response types:
[HttpPost("errors/not-implemented")]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public void ThrowNotImplementedException() { ... }
The SDK validates your test type against the declared status codes. Success tests (AssertPostAsync) are checked
against 2xx responses. Error tests (AssertPostAsErrorAsync) are checked against 4xx/5xx responses.
Tier 2: Expected response JSON fallback
If no [ProducesResponseType] attributes exist, the SDK extracts the status code from your **expected response snapshot
**:
{
"StatusCode": "InternalServerError",
"IsSuccessStatusCode": false,
"Content": {
"Value": {
"title": "Implementation is missing",
"status": 500
}
}
}
This enables validation even when developers forget to add attributes. The SDK parses both numeric (500) and enum
string ("InternalServerError") formats.
Tier 3: Assert method validation
The SDK catches when the assertion method doesn't align with the expected status code. This uses the same standardized error format as other failures:
══════════════════════════════════════════════════════════════
❌ ASSERT METHOD MISMATCH - SUCCESS EXPECTED
══════════════════════════════════════════════════════════════
📦 Test Information
──────────────────────────────────────────────────────────────
Project : MinimalApi.Test
Class : MinimalApi.Test.Api.Persons.PersonEndpointsTests
Method : Should_Create_Person
Line : 88
File : file:///D:/AzureDevOps/AspNetCore.Simple.MsTest.Sdk/src/MinimalApi.Test/Api/Persons/PersonEndpointsTests.cs:88
🌍 HTTP
──────────────────────────────────────────────────────────────
Method : POST
Url : http://localhost/api/v1/persons
Status : Test Type Mismatch
⚠️ Problem
──────────────────────────────────────────────────────────────
The test is declared as a SUCCESS test (AssertPostAsync, AssertGetAsync, etc.)
but the expected response has status code 500 (InternalServerError) which is an ERROR status.
📊 Details
──────────────────────────────────────────────────────────────
Test Type : Success (expects 2xx)
Expected Status : 500 (InternalServerError)
Status Range : Error (4xx/5xx)
✅ Suggested Fix
──────────────────────────────────────────────────────────────
Option 1: Use error assertion method instead
- Use AssertPostAsErrorAsync() or similar error assertion method
Option 2: Update expected response status code
- Change the expected response to have a success status code (200, 201, etc.)
══════════════════════════════════════════════════════════════
This catches common mistakes like using AssertPostAsync when you meant AssertPostAsErrorAsync, or vice versa. The
header clearly shows whether the test expected SUCCESS or ERROR.
Why this matters:
- catches
ProblemDetailsvsValidationProblemDetailsconfusion - works even without explicit attributes
- prevents wrong test type usage
- uses test data that already exists
Endpoint-only validation mode
Sometimes you need to validate that an endpoint exists and returns the correct type, but don't care about the response content. Perfect for process chain tests or when the endpoint is already thoroughly tested elsewhere.
Simple syntax - no response comparison:
// Validates endpoint exists and returns GetAllNodesResponse
// Skips response content comparison automatically
await Client.AssertGetAsync<GetAllNodesResponse>("api/v1/nodes");
With explicit control:
// Same as above, but explicit
await Client.AssertGetAsync<GetAllNodesResponse>("api/v1/nodes",
ignoreResponse: true);
// Full response comparison (default when expectedResult provided)
await Client.AssertGetAsync<GetAllNodesResponse>("api/v1/nodes",
"ExpectedNodes.json");
What gets validated:
- ✅ Endpoint exists and is reachable
- ✅ Response type matches endpoint contract
- ✅ HTTP status code is success (2xx)
- ✅ Request executes without errors
- ⏭️ Response content comparison skipped
Why this matters:
In large systems with lots of backend services, you often have:
- Deep tests that validate full response snapshots (detailed unit/integration tests)
- Process tests that validate multi-step workflows where intermediate calls just need to succeed
This feature lets you write process tests that stay fast and focused:
[TestMethod]
public async Task Complete_User_Registration_Flow()
{
// Step 1: Create user (validate full response)
var user = await Client.AssertPostAsync<CreateUserResponse>(
"api/v1/users",
"NewUser.json",
"NewUser.json");
// Step 2: Send verification email (just validate it succeeds)
await Client.AssertPostAsync<EmailSentResponse>(
$"api/v1/users/{user.Id}/send-verification");
// Step 3: Verify email (just validate it succeeds)
await Client.AssertPostAsync<VerificationResponse>(
$"api/v1/users/{user.Id}/verify");
// Step 4: Get final user state (validate full response)
await Client.AssertGetAsync<GetUserResponse>(
$"api/v1/users/{user.Id}",
"VerifiedUser.json");
}
Response as C# Objects
Instead of JSON strings, you can use C# objects for both request and response. This gives you compile-time type safety, better IDE support, and eliminates string-based JSON files for simple test cases.
Traditional JSON-based approach:
[TestMethod]
public Task Should_Create_Person()
{
return Client.AssertPostAsync<Person>(
"api/v1/persons",
"CreatePerson.json", // Request JSON file
"ExpectedPerson.json"); // Expected response JSON file
}
New object-based approach:
[TestMethod]
public Task Should_Create_Person()
{
var personToCreate = new Person(
Id: 0,
Name: "Son",
FirstName: "Goku",
Age: 99,
Emails: ImmutableList<Email>.Empty);
var expectedPerson = new Person(
Id: 1,
Name: "Son",
FirstName: "Goku",
Age: 99,
Emails: ImmutableList<Email>.Empty);
return Client.AssertPostAsync("api/v1/persons",
personToCreate,
expectedPerson);
}
Benefits:
- ✅ Type safety: Compiler catches errors before runtime
- ✅ Refactoring support: Rename properties with IDE refactoring tools
- ✅ IntelliSense: Full autocomplete for object properties
- ✅ Less boilerplate: No need to create JSON files for simple cases
- ✅ Same validation: Full HTTP response snapshots, structured diffs, curl generation
- ✅ Flexible: Mix and match with JSON files as needed
Supported methods:
All assert methods support object-based responses:
// GET with object response
await Client.AssertGetAsync("api/v1/persons/1", expectedPerson);
// POST with object request and response
await Client.AssertPostAsync("api/v1/persons", requestPerson, expectedPerson);
// PUT with object request and response
await Client.AssertPutAsync("api/v1/persons", requestPerson, expectedPerson);
// PATCH with object request and response
await Client.AssertPatchAsync("api/v1/persons", requestPerson, expectedPerson);
// QUERY with object request and response
await Client.AssertQueryAsync("api/v1/persons/search", searchRequest, expectedResults);
// DELETE with object response
await Client.AssertDeleteAsync<DeleteConfirmation>("api/v1/persons/1", expectedConfirmation);
Error scenarios with objects:
[TestMethod]
public Task Should_Return_NotFound_Error()
{
var expectedError = new
{
Title = "Person not found",
Status = 404,
Detail = "The person with the Id: 999 does not exist",
Id = 999
};
return Client.AssertGetAsErrorAsync("api/v1/persons/999",
expectedError,
skipEndpointValidation: true,
expectedHttpStatusCode: HttpStatusCode.NotFound);
}
Ignoring dynamic fields:
Use differenceFunc to ignore generated IDs or timestamps:
[TestMethod]
public Task Should_Create_Person_Ignore_Id()
{
var personToCreate = TestHelpers.CreateValidPerson();
var expectedPerson = new Person(
Id: 0, // Will be ignored
Name: personToCreate.Name,
FirstName: personToCreate.FirstName,
Age: personToCreate.Age,
Emails: personToCreate.Emails);
return Client.AssertPostAsync("api/v1/persons",
personToCreate,
expectedPerson,
differenceFunc: diffs =>
diffs.Where(d => !d.MemberPath.Contains("id")));
}
Or use the differenceFilter predicate shorthand — same result, no manual Where:
return Client.AssertPostAsync("api/v1/persons",
personToCreate,
expectedPerson,
differenceFilter: d => !d.MemberPath.Contains("id"));
See Predicate shorthand: differenceFilter for details.
When to use objects vs JSON files:
| Scenario | Use |
|---|---|
| Simple, stable test data | C# objects - Type-safe, less overhead |
| Complex nested structures | JSON files - Easier to read and maintain |
| Dynamic test data generation | C# objects - Programmatic control |
| Snapshot-driven workflows | JSON files - File-based test discovery |
| Shared test data across tests | JSON files - Reusable snapshots |
| Type-checked domain models | C# objects - Compile-time safety |
Mixing approaches:
You can mix objects and JSON files based on your needs:
// Request as object, expected response from JSON file
await Client.AssertPostAsync<Person>(
"api/v1/persons",
personToCreate,
"ExpectedPerson.json");
// Request from JSON file, expected response as object
await Client.AssertPostAsync(
"api/v1/persons",
"CreatePerson.json",
expectedPerson);
The SDK automatically serializes objects to JSON and performs the same deep comparison, structured diff, and HTTP context output as with JSON files.
Skip endpoint validation
Sometimes you need to test external APIs or use different response types than what the endpoint declares. In these cases, endpoint validation becomes a blocker rather than a helper.
When to skip endpoint validation:
- Testing external APIs where endpoint metadata is not available
- Using a different response type than defined in the endpoint contract
- Working with OpenAPI specs not yet integrated into your codebase
- Testing legacy endpoints without proper
[ProducesResponseType]attributes
How to use it:
// Test external API without endpoint validation
await Client.AssertGetAsync<ExternalApiResponse>(
"https://external-api.com/v1/data",
"ExpectedResponse.json",
skipEndpointValidation: true);
// Use custom response type for endpoint
await Client.AssertPostAsync<CustomResponse>(
"api/v1/users",
"Request.json",
"Response.json",
skipEndpointValidation: true);
What gets validated when skipped:
- ✅ HTTP status code matches expectation (success vs error)
- ✅ Response content comparison (if
expectedResultprovided) - ✅ Request executes successfully
- ⏭️ Endpoint metadata validation skipped
- ⏭️ Response type contract checking skipped
What gets skipped:
- Type checking against
[ProducesResponseType]attributes - Endpoint existence validation
- Status code to response type mapping
Difference from ignoreResponse:
| Feature | ignoreResponse: true |
skipEndpointValidation: true |
|---|---|---|
| Validates endpoint exists | ✅ Yes | ❌ No |
| Validates response type matches endpoint | ✅ Yes | ❌ No |
| Compares response content | ❌ No | ✅ Yes (if expectedResult provided) |
| Use case | Process tests where call must succeed | External APIs or custom response types |
Example: Testing external API
[TestMethod]
public async Task Should_Fetch_GitHub_User()
{
// GitHub API is external - no endpoint metadata available
await Client.AssertGetAsync<GitHubUser>(
"https://api.github.com/users/octocat",
"GitHubUser.json",
skipEndpointValidation: true);
}
Example: Custom response transformation
[TestMethod]
public async Task Should_Transform_Response()
{
// Endpoint returns User, but we transform to UserViewModel in test
await Client.AssertGetAsync<UserViewModel>(
"api/v1/users/123",
"UserViewModel.json",
skipEndpointValidation: true);
}
Future enhancement:
Later versions may support OpenAPI spec integration for external APIs, allowing endpoint validation even for external services. This would involve downloading and parsing OpenAPI specs at runtime - a bigger round trip that's not currently implemented.
Why this saves ridiculous amounts of time
Traditional API testing
Typical API tests tend to look like this:
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString());
Assert.AreEqual("Son", body.Name);
Assert.AreEqual("Goku", body.FirstName);
Assert.AreEqual(99, body.Age);
// ...and so on
That approach costs time in three places:
- writing the assertions
- maintaining them when the contract changes
- figuring out what actually broke
With this SDK
await Client.AssertPostAsync<UserResponse>(
"api/v1/users",
"CreateUser.json",
"CreateUser.json");
You get:
- full-response verification instead of cherry-picked assertions
- automatic regression detection when new fields appear
- structured diff output instead of vague failures
- replayable
curloutput instead of manual reproduction steps
Boilerplate reduction that actually matters
| Task | Traditional approach | This SDK |
|---|---|---|
| Add a new edge case | Add DataRow + add JSON + keep them in sync |
Add one JSON file |
| Validate headers + body + status | Multiple asserts | One snapshot |
| Reproduce a failed request | Rebuild it manually | Paste generated curl |
| See nested mismatch location | Manually inspect payloads | Read MemberPath |
| Update snapshots after intentional API changes | Rewrite asserts | Enable snapshot update mode |
The real multiplier: JSON-driven scaling
With DynamicRequestLocator, test count scales with files, not attributes.
- 3 scenarios? Add 3 files
- 30 edge cases? Add 30 files
- new bug found in production? Add one JSON file and you have a permanent regression test
That is why this feels like a productivity tool, not just a test library.
Unique features
DynamicRequestLocator: add a JSON file, get a test
This is the killer idea.
[DataTestMethod]
[DynamicRequestLocator]
public Task Should_Create_User(string requestFileName)
{
return Client.AssertPostAsync<UserResponse>(
"api/v1/users",
requestFileName,
requestFileName);
}
If the Requests folder contains:
ValidUser.jsonAdminUser.jsonMissingField.json
then the test runner gets one case per file automatically.
No manual [DataRow]. No sync issues. No silent gaps.
Why it matters:
- adding a case is just adding a file
- deleting a case is just deleting a file
- file names become readable test names
- coverage naturally stays aligned with your snapshot set
If you have many input variations, this feature alone changes the economics of testing.
Full HTTP response snapshots
This SDK validates the full HTTP response, not just the JSON body.
A single snapshot can include:
- response body
- status code
- headers
- trailing headers
- success state
{
"Content": {
"Headers": [
{
"Key": "Content-Type",
"Value": [ "application/json; charset=utf-8" ]
}
],
"Value": {
"Id": 1,
"Name": "Son"
}
},
"StatusCode": "OK",
"Headers": [],
"TrailingHeaders": [],
"IsSuccessStatusCode": true
}
That means changes in headers, status, or response shape are caught by the same test.
Structured diffs with deep MemberPath precision
When a snapshot fails, you do not get a vague object mismatch. You get exact paths.
----------------------------------------------------------------------------------
| MemberPath | SonGokuNewResponse.json | CurrentResult | MismatchType |
----------------------------------------------------------------------------------
| content.value.name | Son 1 | Son | ValueDifference |
----------------------------------------------------------------------------------
This is especially valuable when:
- payloads are nested
- arrays are involved
- a response changed in only one deep property
- you need to distinguish missing vs changed values
Supported mismatch types include:
ValueDifferenceMissingInFirstMissingInSecond
Array length mismatches
When array lengths differ, the SDK consolidates element-level differences into a single array-level entry.
Instead of showing:
| content.value.emails[0] | null | {"emailAddress": "test@example.com"} | MissingInFirst |
| content.value.emails[1] | null | {"emailAddress": "user@example.com"} | MissingInFirst |
You get:
DIFFERENCES
-----------------------------------------------------------------------------------
| MemberPath | NewPersonParameter.json | CurrentResult | MismatchType |
-----------------------------------------------------------------------------------
| content.value.emails | [] (0 items) | [2 item(s)] | MissingInFirst |
-----------------------------------------------------------------------------------
This makes it immediately clear that the issue is array length, not individual element values.
When arrays have mixed differences (some elements changed, some missing), the SDK shows element-level details. Consolidation only happens when all elements are uniformly missing or added.
Built-in curl generation
Every failed test includes a ready-to-run curl command.
curl \
--request POST 'https://localhost:5001/api/v1/users' \
--header 'Content-Type: application/json' \
--data-raw '{ ... }'
That means:
- faster debugging
- easier collaboration
- simpler reproduction outside the test runner
- better handoff between test failures and API investigation
The generated curl output alone removes a surprising amount of wasted time.
Automatic test generation from live traffic
You can generate tests from actual API usage.
app.UseTestCreator();
The middleware captures requests and responses and turns them into test assets.
This is useful for:
- bootstrapping regression coverage quickly
- documenting legacy APIs
- converting exploratory testing into permanent test cases
- generating real examples from live behavior
Enum test cases without DataRow boilerplate
If you need one test per enum value, use EnumTestCase.
[DataTestMethod]
[EnumTestCase<Status>()]
public async Task Should_Handle_Status(Status status)
{
// test logic
}
Instead of manually listing enum values with [DataRow], test cases are generated automatically.
This is small, but on large suites it removes a lot of repetitive noise.
Snapshot auto-update mode
When an API change is intentional, updating snapshots should be easy.
You can enable snapshot writing per test:
await Client.AssertPostAsync<CreateUserResponse>(
"api/v1/users",
"CreateUser.json",
"CreateUser.json",
writeResponse: true);
Or globally:
AssertObjectExtensions.WriteResponse = true;
Or via environment variable:
AspNetCoreSimpleMsTestSdk__WriteResponse=true
Use it when:
- refactoring response contracts
- updating baselines after intentional changes
- regenerating snapshots across a suite
Global and scoped ignore strategies
Some values are dynamic and should not break the test: timestamps, GUIDs, trace IDs, database-generated IDs.
Global ignore example:
AssertObjectExtensions.DifferenceFunc = differences =>
{
foreach (var difference in differences)
{
if (difference.MemberPath.Contains("timestamp"))
{
continue;
}
yield return difference;
}
};
Scoped ignore example:
await Client.AssertPostAsync<AddUserReponse>(
"api/v1/users",
"NewUser.json",
"NewUser.json",
differenceFunc: differences =>
{
foreach (var difference in differences)
{
if (difference.MemberPath == "Content.Value.Id")
{
continue;
}
yield return difference;
}
});
This lets you keep snapshots strict where they should be strict and flexible where they must be flexible.
Predicate shorthand: differenceFilter
The DifferenceFunc examples above require you to iterate the differences yourself. If you only want to
decide per difference whether to keep it, use the differenceFilter predicate instead — the SDK does the
iteration for you. Return true to keep a difference, false to ignore it (same semantics as LINQ Where).
Global:
// Keep every difference except database-generated ids.
AssertObjectExtensions.DifferenceFilter = difference => difference.MemberPath != "Content.Value.Id";
Scoped (per assert):
await Client.AssertPostAsync<AddUserReponse>(
"api/v1/users",
"NewUser.json",
"NewUser.json",
differenceFilter: difference => !difference.MemberPath.Contains("timestamp"));
differenceFilter runs in addition to DifferenceFunc: a difference is reported only when the global
DifferenceFunc, the per-assert