Advanced Features
Explore advanced features and patterns for power users.
Custom Matchers
Use predicates for advanced matching logic:
mock.ForGet()
.WithPath("/api/data")
.With(request => request.Headers.Contains("X-API-Key"))
.RespondsWithStatus(HttpStatusCode.OK);
Inspect Request Body
mock.ForPost()
.WithPath("/api/test")
.With(req => req.Body!.Contains("something"))
.RespondsWithStatus(HttpStatusCode.NoContent);
Async Predicate Matching
mock.ForGet()
.WithPath("/api/async")
.With(async req =>
{
await Task.Delay(1);
return req.Uri!.Query == "?q=test";
})
.RespondsWithStatus(HttpStatusCode.OK);
If no mock matches, an UnexpectedRequestException is thrown when FailOnUnexpectedCalls is true (default).
Body Matching
Match request bodies using different strategies:
Wildcard Pattern
mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.RespondsWithStatus(HttpStatusCode.NoContent);
JSON Equivalence
Layout and whitespace independent, using a raw JSON string:
mock.ForPost()
.WithPath("/api/json")
.WithBodyMatchingJson("{\"name\": \"John\", \"age\": 30}")
.RespondsWithStatus(HttpStatusCode.NoContent);
If the body cannot be parsed as JSON for WithBodyMatchingJson, a RequestMatchingException is thrown.
Object Serialized to JSON
Pass an object directly and let Mockly serialize it to JSON for matching. This is useful when you have a strongly-typed request body:
mock.ForPatch()
.WithPath("/api/relationships/42")
.WithBody(new
{
EntityKey = "TheRuleKey",
RepresentativeId = "abc123"
})
.RespondsWithStatus(HttpStatusCode.NoContent);
The object is serialized using JsonSerializer with default options and compared to the request body using JSON equivalence, ignoring differences in whitespace and layout.
Custom JSON Options
You can supply custom JsonSerializerOptions for the body matching:
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
mock.ForPost()
.WithPath("/api/data")
.Using(options)
.WithBody(new { UserId = 42, UserName = "Alice" })
.RespondsWithStatus(HttpStatusCode.NoContent);
Regular Expression
mock.ForPost()
.WithPath("/api/test")
.WithBodyMatchingRegex(".*something.*")
.RespondsWithStatus(HttpStatusCode.NoContent);
Forcing a Body to be Treated as Textual
Body matchers (WithBody, WithBodyMatchingJson, WithBodyMatchingRegex, and form-field matching) only work when the request's Content-Type is recognized as textual (text/*, multipart/*, application/json, application/xml, and similar). If your request uses a Content-Type Mockly doesn't recognize as textual — but the body is actually text — opt in with TreatBodyAsTextual():
mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.TreatBodyAsTextual()
.RespondsWithStatus(HttpStatusCode.NoContent);
Request Body Prefetching
By default, Mockly prefetches the request body for matchers. You can disable this to defer reading content inside your predicate:
var mock = new HttpMock { PrefetchBody = false };
RequestInfo? captured = null;
mock.ForPost()
.WithPath("/api/test")
.With(req =>
{
captured = req; // req.Body can be read lazily here by your predicate
return true;
})
.RespondsWithStatus(HttpStatusCode.OK);
What PrefetchBody Does
- Purpose: When
PrefetchBodyistrue(default), Mockly eagerly reads and caches the HTTP request body intoRequestInfo.Bodyso that matchers and later assertions can inspect it without re-reading the stream. - When to disable: Turn it off for scenarios with large or streaming content where reading the body up front is expensive or undesirable. In that case,
RequestInfo.Bodywill benullunless your own predicate reads it. - Impact on assertions: Body-based assertions require the body to be available. Keep
PrefetchBodyenabled if you plan to assert on the request body after the call.
Record and Replay
Record unmatched requests against a real service and replay them later without a network connection. Recording files use the HTTP Archive (HAR) JSON format.
Recording Requests
Use RecordingTo to send unmatched requests to the real service and write their requests and responses to a file:
using var mock = new HttpMock()
.RecordingTo("Recordings/github.json");
HttpClient client = mock.GetClient();
HttpResponseMessage response = await client.GetAsync(
"https://api.github.com/repos/dennisdoomen/mockly/issues");
RecordingTo enables pass-through requests. Configured mocks still take precedence, so Mockly records only unmatched
requests.
Mockly replaces the values of Authorization, Cookie, Proxy-Authorization, and Set-Cookie headers with
[REDACTED]. Call KeepSensitiveRecordingValues before RecordingTo only if it is safe to store the values:
using var mock = new HttpMock()
.KeepSensitiveRecordingValues()
.RecordingTo("Recordings/github.json");
Replaying Requests
Use LoadRecordings to serve responses from a recording without contacting the real service:
using var mock = new HttpMock()
.LoadRecordings("Recordings/github.json");
HttpClient client = mock.GetClient();
HttpResponseMessage response = await client.GetAsync(
"https://api.github.com/repos/dennisdoomen/mockly/issues");
Mockly selects a recording by HTTP method and exact URL. If the recording contains a request body, Mockly also requires
the request body to match. If no recording matches, Mockly handles the request as an unexpected request. You can add
PassThroughUnmatched to contact the real service for missing recordings.
Limiting Mock Invocations
Sometimes you want a mock to respond only a limited number of times. You can restrict a mock using the fluent methods Once(), Twice(), or Times(int count) on the request builder.
var mock = new HttpMock();
// Single-use response
mock.ForGet()
.WithPath("/api/item")
.RespondsWithStatus(HttpStatusCode.OK)
.Once();
// Exactly two times
mock.ForPost()
.WithPath("/api/items")
.RespondsWithJsonContent(new { ok = true })
.Twice();
// Exactly N times
mock.ForDelete()
.WithPath("/api/items/*")
.RespondsWithEmptyContent()
.Times(3);
Behavior Notes
- Exhausted mocks are skipped when matching. If no other non-exhausted mock matches and
FailOnUnexpectedCallsistrue(default), anUnexpectedRequestExceptionis thrown. - The mocks are evaluated in the order they were created.
- The default for mocks without limits is unlimited invocations
- Invocation limits and sequenced responses are independent. A
Times(2)mock with three configured responses still stops matching after the second call. - The verification helpers consider limits:
HttpMock.AllMocksInvokedreturnstrueonly when each mock has been called at least once or has reached its configuredTimes(..)limit.HttpMock.GetUninvokedMocks()lists mocks that haven't reached their required count (or have 0 calls for unlimited mocks).
Simulating Response Latency
Use After(TimeSpan delay) to delay a response, simulating a slow endpoint. This is useful for exercising timeout, cancellation, and resilience (e.g. Polly) behavior.
var mock = new HttpMock();
mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(TimeSpan.FromSeconds(2));
Behavior Notes
- The delay is awaited before the response is produced and honors the
CancellationTokenflowing from the HTTP pipeline. - If
HttpClient.Timeoutis shorter than the delay, the request throws aTaskCanceledException, just like a realHttpClient. - If the
CancellationTokenpassed to the request is cancelled while the delay is in progress, anOperationCanceledExceptionis thrown.
Simulating Network Failures
Use ThrowsException or TimesOut to simulate a network-level failure instead of returning a response. This lets
you verify retry, circuit-breaker and other resilience behavior without relying on a real, flaky network.
var mock = new HttpMock();
mock.ForGet()
.WithPath("/flaky")
.ThrowsException<HttpRequestException>();
Throw a specific exception instance instead:
mock.ForGet()
.WithPath("/flaky")
.ThrowsException(new HttpRequestException("connection reset"));
Simulate an HttpClient timeout, which throws a TaskCanceledException:
mock.ForGet()
.WithPath("/slow")
.TimesOut();
Behavior Notes
- The exception is propagated to the
HttpClientcaller rather than being converted into a500 Internal Server Errorresponse. - The matching request is still recorded (e.g. in
HttpMock.Requests) before the exception is thrown. ThrowsException(Exception)throws the same instance on every matching invocation;ThrowsException<TException>()creates a fresh instance each time.- Combine with invocation limits (
Once(),Times(n)) to fail only the first few calls before succeeding.
Request Collection
Capture requests for specific mocks:
var capturedRequests = new RequestCollection();
mock.ForPatch()
.WithPath("/api/update")
.CollectingRequestsIn(capturedRequests)
.RespondsWithStatus(HttpStatusCode.NoContent);
// After making requests
capturedRequests.Count.Should().Be(2);
capturedRequests.First().WasExpected.Should().BeTrue();
Inspecting the Captured Body
Each CapturedRequest exposes the request body both as text (Body) and as raw bytes (RawBody):
var capturedRequest = capturedRequests.First();
// The body decoded as text, or null if it's not textual (see IsBodyLikelyTextual)
capturedRequest.Body.Should().Be("hello");
// The raw, undecoded bytes of the body
capturedRequest.RawBody.Should().Equal(Encoding.UTF8.GetBytes("hello"));
Body and RawBody are only populated when HttpMock.PrefetchBody is true (the default). See Request Body Prefetching above.
Assertions
Mockly provides extensive support for test assertions through FluentAssertions. For a full guide on available assertions for mocks, collections, and requests, see the Assertions page.