![]() |
VOOZH | about |
dotnet add package AspNetCore.Simple.MsTest.Sdk --version 9.1.0-alpha.49
NuGet\Install-Package AspNetCore.Simple.MsTest.Sdk -Version 9.1.0-alpha.49
<PackageReference Include="AspNetCore.Simple.MsTest.Sdk" Version="9.1.0-alpha.49" />
<PackageVersion Include="AspNetCore.Simple.MsTest.Sdk" Version="9.1.0-alpha.49" />Directory.Packages.props
<PackageReference Include="AspNetCore.Simple.MsTest.Sdk" />Project file
paket add AspNetCore.Simple.MsTest.Sdk --version 9.1.0-alpha.49
#r "nuget: AspNetCore.Simple.MsTest.Sdk, 9.1.0-alpha.49"
#:package AspNetCore.Simple.MsTest.Sdk@9.1.0-alpha.49
#addin nuget:?package=AspNetCore.Simple.MsTest.Sdk&version=9.1.0-alpha.49&prereleaseInstall as a Cake Addin
#tool nuget:?package=AspNetCore.Simple.MsTest.Sdk&version=9.1.0-alpha.49&prereleaseInstall as a Cake Tool
AspNetCore.Simple.MsTest.SdkAPI 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.
[TestMethod]
[DynamicRequestLocator]
public Task Should_Create_User(string useCase)
{
return Client.AssertPostAsync<UserResponse>("api/v1/users",
useCase,
useCase);
}
dotnet add package AspNetCore.Simple.MsTest.Sdk
[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) =>
{
// IMPORTANT: Required for endpoint validation and assertable HTTP client features
services.AddAssertableHttpClient(configuration);
});
Client = _apiTestBase.CreateClient();
// IMPORTANT: Required to make all HttpClientAssertExtensions 100% functional
HttpClientAssertExtensions.Setup(_apiTestBase.Services);
}
protected static HttpClient Client { get; private set; } = null!;
[AssemblyCleanup]
public static void AssemblyCleanup()
{
_apiTestBase.Dispose();
Client.Dispose();
}
}
[TestClass]
public class UserTests : ApiTestBase
{
[TestMethod]
public Task Should_Create_User()
{
return Client.AssertPostAsync<UserResponse>(
"api/v1/users",
"CreateUser.json",
"CreateUser.json");
}
}
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
}
Run the test and you get:
curl for instant reproductionMemberPath pathscurl output on failuresDynamicRequestLocatorMost API testing tools make you choose between speed, coverage, and debuggability.
This SDK does not.
It is built around a simple idea:
content.value.emails[1].typecurl reproduces the problem immediatelyThat combination changes how API testing feels in practice. Less plumbing. More coverage. Faster debugging.
Prefer this:
"NewUser.json"
Not this:
"Users.V1.Payloads.NewUser.json"
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:
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
Requests contains input payloadsResponses contains expected snapshotsDynamicRequestLocator can discover request files automaticallyExample:
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.
The SDK provides context-specific error outputs that make debugging fast and intuitive. Each failure type has a dedicated format with actionable information.
| 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.
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":[]}'
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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'
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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.)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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"}'
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
You immediately see:
content.value.name with deep path precisioncurlThat 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.
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":[]}'
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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:
ProblemDetails vs ValidationProblemDetails confusionSometimes 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:
Why this matters:
In large systems with lots of backend services, you often have:
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");
}
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:
[ProducesResponseType] attributesHow 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:
expectedResult provided)What gets skipped:
[ProducesResponseType] attributesDifference 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.
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:
await Client.AssertPostAsync<UserResponse>(
"api/v1/users",
"CreateUser.json",
"CreateUser.json");
You get:
curl output instead of manual reproduction steps| 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 |
With DynamicRequestLocator, test count scales with files, not attributes.
That is why this feels like a productivity tool, not just a test library.
DynamicRequestLocator: add a JSON file, get a testThis 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.jsonthen the test runner gets one case per file automatically.
No manual [DataRow]. No sync issues. No silent gaps.
Why it matters:
If you have many input variations, this feature alone changes the economics of testing.
This SDK validates the full HTTP response, not just the JSON body.
A single snapshot can include:
{
"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.
MemberPath precisionWhen 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:
Supported mismatch types include:
ValueDifferenceMissingInFirstMissingInSecondWhen 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.
curl generationEvery 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:
The generated curl output alone removes a surprising amount of wasted time.
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:
DataRow boilerplateIf 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.
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:
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.
If your snapshot needs a runtime value, use placeholders.
var user = await CreateUserAsync();
await Client.AssertGetAsync<GetUserByIdResponse>(
$"api/v1/users/{user.Id}",
"GetUser.json",
[
("$Id$", user.Id)
]);
Snapshot:
{
"content": {
"value": {
"id": "$Id$",
"name": "Son",
"age": 99
}
}
}
This keeps snapshots deterministic while still supporting dynamic test flows.
For eventual consistency or flaky integration points, use SnapshotTestMethod.
[SnapshotTestMethod(maxRetries: 3)]
public async Task Should_Eventually_Be_Consistent()
{
await Client.AssertGetAsync<Response>("api/eventual", "Response.json");
}
Useful for:
[TestClass]
public class Persons : ApiTestBase
{
[TestMethod]
public Task Should_Be_Able_To_Post_A_Person_By_Json()
{
return Client.AssertPostAsync<Person>(
"api/tests/v1/persons",
"SonGoku.json",
"SonGoku.json");
}
}
Possible, but better for small payloads only.
[TestMethod]
public Task Should_Return_No_Users_If_No_One_Was_Added()
{
return Client.AssertGetAsync<GetAllUsersResponse>(
"v1/users",
"""{ "Users": [] }""");
}
private static IEnumerable<Difference> IgnoreId(IImmutableList<Difference> differences)
{
foreach (var difference in differences)
{
if (difference.MemberPath == "Content.Value.Id")
{
continue;
}
yield return difference;
}
}
This is available too, but the primary value of the SDK is the ASP.NET Core API testing workflow.
[TestMethod]
public void Simple_Object_Comparison()
{
var person1 = new Person("Son", "Goku", 29);
var person2 = new Person("Muten", "Roshi", 63);
Assert.That.ObjectsAreEqual(person1, person2, title: "Persons are not equal");
}
Example output:
Persons are not equal
----------------------------------
| MemberPath | person1 | person2 |
----------------------------------
| Name | Son | Muten |
----------------------------------
| FamilyName | Goku | Roshi |
----------------------------------
| Age | 29 | 63 |
----------------------------------
The SDK uses a Strategy Pattern for comparisons, making it extensible for custom types.
Built-in strategies:
StringComparisonStrategy - Line-by-line comparison (like git diff) for string types
.txt files as snapshots for string comparisonsJsonComparisonStrategy - Deep object comparison via JSON serialization (fallback for all non-string types)
.json files as snapshots for object comparisonsHow it works:
CanCompare()Example: String comparison from file
[TestMethod]
public void Compare_Error_Message()
{
var error = GetErrorMessage();
// Uses StringComparisonStrategy for line-by-line comparison
Assert.That.ObjectsAreEqual<string>(
expectedObjectAsJson: "ExpectedError.txt",
currentObject: error.Message);
}
Example: Custom CSV comparison strategy
public class CsvComparisonStrategy : ComparisonStrategyBase<CsvData>
{
protected override ComparisonResult CompareTyped(ObjectAssertContext<CsvData> context)
{
var expected = ParseCsv(context.ResolvedExpectedJson);
var current = context.Current;
var differences = CompareCsvRows(expected, current);
return new ComparisonResult
{
Differences = differences,
FormattedExpected = FormatCsv(expected),
FormattedCurrent = FormatCsv(current),
HasSchemaMismatch = differences.Any(d => d.MismatchType != MismatchType.ValueDifference)
};
}
}
Registration:
// In your test setup (before running tests)
services.AddSingleton<ISpecificComparisonStrategy, CsvComparisonStrategy>();
services.AddComparisonStrategy(); // Adds built-in strategies (String + JSON)
Why use ComparisonStrategyBase<T>?
CanCompare()CompareTyped() with your comparison logicWhen NOT to use the base class:
Don't use ComparisonStrategyBase<T> for fallback strategies that handle multiple types (like JsonComparisonStrategy). Implement ISpecificComparisonStrategy directly instead.
This SDK is not generic snapshot tooling with HTTP support bolted on. It is designed around ASP.NET Core API testing.
That is why the core experience centers on:
The assertion flow is pipeline-based:
That means failures stop early and come with relevant context instead of a long tail of noisy assertions.
The comparison system uses a Strategy Pattern, making it extensible for custom types beyond the built-in JSON and string comparisons.
Traditional tests often validate only the fields someone remembered to assert.
Snapshot testing flips that:
That is exactly what you want for API regression safety.
The SDK enforces type safety at the HTTP layer by distinguishing between compile-time types (TResult) and runtime validation types (ExpectedType).
Non-generic methods = NoContent endpoints (204):
// Non-generic signatures expect void response (204 NoContent)
await Client.AssertDeleteAsync("api/v1/items/123");
await Client.AssertPostAsync("api/v1/items", "NewItem.json");
await Client.AssertPutAsync("api/v1/items/123", "UpdatedItem.json");
await Client.AssertPatchAsync("api/v1/items/123", "PatchItem.json");
// β ExpectedType = typeof(void)
// β Validates endpoint returns 204 NoContent
Generic methods = Typed responses (200, 201, etc.):
// Generic signatures expect typed responses (200 OK, 201 Created with body)
await Client.AssertDeleteAsync<DeleteItemResponse>("api/v1/items/123",
"Expected.json");
await Client.AssertPostAsync<CreateItemResponse>("api/v1/items",
"NewItem.json",
"Expected.json");
await Client.AssertPutAsync<UpdateItemResponse>("api/v1/items/123",
"UpdatedItem.json",
"Expected.json");
await Client.AssertPatchAsync<PatchItemResponse>("api/v1/items/123",
"PatchItem.json",
"Expected.json");
// β ExpectedType = typeof(ResponseType)
// β Validates endpoint returns 2xx with response body
Why this distinction matters:
HTTP semantics demand different handling:
Using the wrong method signature catches real bugs:
// β Bug: Test expects void but endpoint returns 200 with body
await Client.AssertPostAsync("api/v1/items", "NewItem.json");
// β Validator Error: "Expected void, got CreateItemResponse"
// β
Fix: Use correct generic signature
await Client.AssertPostAsync<CreateItemResponse>("api/v1/items",
"NewItem.json",
"Expected.json");
Supported methods with NoContent variants:
| HTTP Method | NoContent (204) | With Response Body (200/201) |
|---|---|---|
| DELETE | AssertDeleteAsync() |
AssertDeleteAsync<T>() |
| POST | AssertPostAsync() |
AssertPostAsync<T>() |
| PUT | AssertPutAsync() |
AssertPutAsync<T>() |
| PATCH | AssertPatchAsync() |
AssertPatchAsync<T>() |
Real-world examples:
// Command-style endpoint (no response needed)
await Client.AssertPostAsync("api/v1/notifications/send", "Notification.json");
// Update endpoint that returns updated entity
await Client.AssertPutAsync<User>("api/v1/users/123",
"UpdateUser.json",
"UpdatedUser.json");
// Partial update without response
await Client.AssertPatchAsync("api/v1/users/123/status", "StatusUpdate.json");
// Delete with confirmation response
await Client.AssertDeleteAsync<DeleteConfirmation>("api/v1/items/123",
"DeletedItem.json");
This prevents:
[ProducesResponseType] and actual endpoint behaviorThe live traffic capture feature exists because good API tests often start with a real request. Recording that request and response into reusable test assets is part of the design, not an afterthought.
This SDK is battle-tested in production environments.
Repository: https://renepeuser.visualstudio.com/_git/AspNetCore.Simple.MsTest.Sdk
Copyright 2021-2026 (c) Rene Peuser. All rights reserved.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 9.3.6 | 920 | 6/18/2026 |
| 9.3.5 | 101 | 6/17/2026 |
| 9.3.4 | 66 | 6/17/2026 |
| 9.3.3 | 1,027 | 6/14/2026 |
| 9.3.2 | 1,050 | 6/8/2026 |
| 9.3.1 | 36 | 6/8/2026 |
| 9.3.0 | 1,739 | 6/7/2026 |
| 9.2.0 | 325 | 6/5/2026 |
| 9.1.3 | 130 | 6/5/2026 |
| 9.1.2 | 57 | 6/5/2026 |
| 9.1.1 | 218 | 6/2/2026 |
| 9.1.0 | 53 | 6/2/2026 |
| 9.1.0-alpha.49 | 67 | 6/2/2026 |
| 9.0.15 | 45 | 5/25/2026 |
| 9.0.14 | 602 | 5/22/2026 |
| 9.0.13 | 136 | 5/22/2026 |
| 9.0.12 | 67 | 5/22/2026 |
| 9.0.11 | 518 | 5/19/2026 |
| 9.0.10 | 65 | 5/19/2026 |
| 9.0.9 | 39 | 5/19/2026 |
MAJOR RELEASE - Complete Core Rewrite
CORE (New Architecture):
- AssertService as central orchestrator
- HttpAssertContext for type-safe HTTP assertion pipelines
- ObjectAssertContext for type-safe object comparison pipelines
- AssertAbleClient with full DI support
- HttpAssertExtensions.Setup for backwards compatibility
ARCHITECTURE (Design Patterns):
- Pipeline-based execution flow
- Strategy Pattern for outputs/comparisons
- Builder Pattern for all formatters
- Feature-based DI registration (AddXxx per component)
NEW FEATURES:
- Smart endpoint validation with multi-tier fallback
- Type mismatch detection with suggested fixes
- Consolidated array diff reporting
- Endpoint-only validation mode
- Skip validation option
OUTPUTS:
- Unified ASCII formatting with emoji sections
- Enhanced HTTP context display
- ContextβProblemβSolutionβReproduction flow
QUALITY:
- SOLID principles throughout
- Single responsibility per component
- Full extensibility built-in
- Clean architecture patterns
COMPATIBILITY:
- Existing tests continue to work via extension method bridge
HttpClientAssertExtensions.Setup(_apiTestBase.Services);