About • Key Features • Quick Start • Usage • Advanced Features • Download • Building • Contributing • License
Mockly is a powerful and flexible HTTP mocking library for .NET that makes it easy to test code that depends on HttpClient. It provides a fluent API for configuring HTTP request mocks, capturing request details, and asserting on HTTP interactions in your tests.
The library supports:
- .NET Framework 4.7.2 and higher
- .NET 8.0 and higher
- FluentAssertions 7.x and 8.x integration for expressive test assertions
Unlike other HTTP mocking libraries, Mockly offers:
- Fluent, intuitive API - Chain method calls to build complex mocking scenarios with ease
- Wildcard pattern matching - Match URLs using wildcards (
*) in paths and query strings - Custom matchers - Use predicates for advanced request matching logic
- Request capture & inspection - Automatically capture all requests with full metadata (headers, body, timestamp)
- Powerful assertions - Built-in FluentAssertions extensions for verifying HTTP behavior
- Diagnostic support - Detailed error messages when unexpected requests occur
- Extensibility - Design allows for custom response generators and matchers
- Zero configuration - Works out of the box with sensible defaults
- Performance optimized - Regex patterns are cached for efficient matching
- Invocation limits - Restrict how many times a mock can respond using
Once(),Twice(), orTimes(n)
Mockly is created and maintained by Dennis Doomen, also the creator of FluentAssertions, PackageGuard, Reflectify, Pathy and the .NET Library Starter Kit. It's designed to work seamlessly with modern .NET testing practices and integrates naturally with FluentAssertions for expressive test assertions.
mock.ForGet().WithPath("/api/users/*").RespondsWithJsonContent(user);
mock.ForPost().WithPath("/api/data").WithQuery("?filter=*").RespondsWithStatus(HttpStatusCode.Created);When an unexpected request occurs and there are configured mocks, Mockly helps you diagnose by reporting the closest matching mock (method, scheme/host/path/query) so you can quickly see what to adjust in your setup.
Unexpected request to:
GET http://localhost/fnv_collectiveschemes(111)
Closest matching mock:
GET https://*/fnv_collectiveschemes(123*)
Registered mocks:
- GET https://*/fnv_collectiveschemes
- POST https://*/fnv_collectiveschemes
- GET https://*/fnv_collectiveschemes(123*)
- GET https://*/fnv_collectiveschemes(123*) (1 custom matcher(s)) where (request => request.Uri?.Query == "?$count=1")
- GET https://*/fnv_collectiveschemes(456)
var patches = new RequestCollection();
mock.ForPatch().WithPath("/api/update").CollectingRequestIn(patches);
// After test execution
patches.Count.Should().Be(3);
patches.First().Path.Should().Contain("/api/update");mock.Should().HaveAllRequestsCalled();
mock.Requests.Should().NotBeEmpty();
mock.Requests.Should().NotContainUnexpectedCalls();
// Assert JSON-equivalence using a JSON string (ignores formatting/ordering)
mock.Requests.Should().ContainRequest()
.WithBodyMatchingJson("{ \"id\": 1, \"name\": \"x\" }");
// Assert the body deserializes and is equivalent to an object graph
var expected = new { id = 1, name = "x" };
mock.Requests.Should().ContainRequestForUrl("http://localhost:7021/api/*")
.WithBodyEquivalentTo(expected);
// Assert the body has specific properties (deserialized as a dictionary)
var expectedProps = new Dictionary<string, string>
{
["id"] = "1",
["name"] = "x"
};
mock.Requests.Should().ContainRequest()
.WithBodyHavingPropertiesOf(expectedProps);- JSON content with automatic serialization
- Raw string content
- Empty responses
- Custom HTTP status codes
- Custom response generators
mock.FailOnUnexpectedCalls = true; // Default behavior
// Throws UnexpectedRequestException if an unmocked request is madeInstall the package:
dotnet add package mocklyTo get the assertions, also install one of the two assertion packages, depending on which version of Fluent Assertions you're using:
dotnet add package FluentAssertions.Mockly.v7
dotnet add package FluentAssertions.Mockly.v8Basic usage:
using Mockly;
using FluentAssertions;
// Arrange
var mock = new HttpMock();
mock.ForGet()
.WithPath("/api/users/123")
.RespondsWithJsonContent(new { Id = 123, Name = "John Doe" });
HttpClient client = mock.GetClient();
// Act
var response = await client.GetAsync("http://localhost/api/users/123");
var content = await response.Content.ReadAsStringAsync();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
content.Should().Contain("John Doe");
mock.Should().HaveAllRequestsCalled();Create an HttpMock instance and configure it using the fluent API:
var mock = new HttpMock();
// Mock a GET request
mock.ForGet()
.WithPath("/api/products")
.RespondsWithJsonContent(new[]
{
new { Id = 1, Name = "Product 1" },
new { Id = 2, Name = "Product 2" }
});
// Get the HttpClient
HttpClient client = mock.GetClient();mock.ForGet() // GET requests
mock.ForPost() // POST requests
mock.ForPut() // PUT requests
mock.ForPatch() // PATCH requests
mock.ForDelete() // DELETE requestsExact matching:
mock.ForGet().WithPath("/api/users/123");Wildcard matching:
// Match any user ID
mock.ForGet().WithPath("/api/users/*");
// Match query parameters with wildcards
mock.ForGet()
.WithPath("/api/search")
.WithQuery("?q=*&limit=10");JSON responses:
mock.ForGet()
.WithPath("/api/user")
.RespondsWithJsonContent(new { Id = 1, Name = "John" });String content:
mock.ForGet()
.WithPath("/api/text")
.RespondsWithContent("Hello, World!", "text/plain");Status codes:
mock.ForPost()
.WithPath("/api/create")
.RespondsWithStatus(HttpStatusCode.Created);Empty responses:
mock.ForDelete()
.WithPath("/api/resource/123")
.RespondsWithEmptyContent(HttpStatusCode.NoContent);Custom responses:
mock.ForGet()
.WithPath("/api/custom")
.RespondsWith(request =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.Add("X-Custom-Header", "value");
response.Content = new StringContent("Custom content");
return response;
});Use predicates for advanced matching logic:
mock.ForGet()
.WithPath("/api/data")
.With(request => request.Headers.Contains("X-API-Key"))
.RespondsWithStatus(HttpStatusCode.OK);Inspect the request body in your predicate:
mock.ForPost()
.WithPath("/api/test")
.With(req => req.Body!.Contains("something"))
.RespondsWithStatus(HttpStatusCode.NoContent);Async predicate matching is supported:
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).
Match request bodies using different strategies:
// Wildcard pattern
mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.RespondsWithStatus(HttpStatusCode.NoContent);
// JSON equivalence (layout/whitespace independent)
mock.ForPost()
.WithPath("/api/json")
.WithBodyMatchingJson("{\"name\": \"John\", \"age\": 30}")
.RespondsWithStatus(HttpStatusCode.NoContent);
// Regular expression
mock.ForPost()
.WithPath("/api/test")
.WithBodyMatchingRegex(".*something.*")
.RespondsWithStatus(HttpStatusCode.NoContent);Note: if the body cannot be parsed as JSON for WithBodyMatchingJson, a RequestMatchingException is thrown.
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 and when to use it:
- Purpose: When PrefetchBody is true (default), Mockly eagerly reads and caches the HTTP request body into RequestInfo.Body so 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.Body will be null unless your own predicate reads it.
- Impact on assertions: Body-based assertions (see Assertions section below) require the body to be available. Keep PrefetchBody enabled if you plan to assert on the request body after the call.
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. After the configured number of invocations is reached, the mock is considered exhausted and will no longer match requests.
var mock = new HttpMock();
// Single-use response
mock.ForGet()
.WithPath("/api/item")
.Once()
.RespondsWithStatus(HttpStatusCode.OK);
// Exactly two times
mock.ForPost()
.WithPath("/api/items")
.Twice()
.RespondsWithJsonContent(new { ok = true });
// Exactly N times
mock.ForDelete()
.WithPath("/api/items/*")
.Times(3)
.RespondsWithEmptyContent();Behavior notes:
- Exhausted mocks are skipped when matching. If no other non-exhausted mock matches and
FailOnUnexpectedCallsistrue(default), anUnexpectedRequestExceptionis thrown. - The default for mocks without limits is unlimited invocations; behavior is unchanged from earlier versions.
- 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).
Capture requests for specific mocks:
var capturedRequests = new RequestCollection();
mock.ForPatch()
.WithPath("/api/update")
.CollectingRequestIn(capturedRequests)
.RespondsWithStatus(HttpStatusCode.NoContent);
// After making requests
capturedRequests.Count.Should().Be(2);
capturedRequests.First().WasExpected.Should().BeTrue();Access all captured requests globally:
var allRequests = mock.Requests;
allRequests.Count.Should().Be(5);
allRequests.Should().NotContainUnexpectedCalls();
var firstRequest = allRequests.First();
firstRequest.Method.Should().Be(HttpMethod.Get);
firstRequest.Path.Should().StartWith("/api/");
firstRequest.Timestamp.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));Verify all mocks were called:
mock.Should().HaveAllRequestsCalled();Verify no unexpected requests:
mock.Requests.Should().NotContainUnexpectedCalls();Verify request expectations:
var request = mock.Requests.First();
request.Should().BeExpected();
request.WasExpected.Should().BeTrue();Assert an unexpected request was captured:
var first = mock.Requests.First();
first.Should().BeUnexpected();Collection assertions:
mock.Requests.Should().NotBeEmpty();
mock.Requests.Should().HaveCount(3);
capturedRequests.Should().BeEmpty();Body assertions on captured requests:
Use these to assert on the JSON body of a previously captured request:
// Assert JSON-equivalence using a JSON string (ignores formatting/ordering)
mock.Requests.Should().ContainRequest()
.WithBodyMatchingJson("{ \"id\": 1, \"name\": \"x\" }");
// Assert the body deserializes and is equivalent to an object graph
var expected = new { id = 1, name = "x" };
mock.Requests.Should().ContainRequest()
.WithBodyEquivalentTo(expected);
// Assert the body has specific properties (deserialized as a dictionary)
var expectedProps = new Dictionary<string, string>
{
["id"] = "1",
["name"] = "x"
};
mock.Requests.Should().ContainRequest()
.WithBodyHavingPropertiesOf(expectedProps);Notes:
- These assertions operate on captured requests (mock.Requests). They are part of the FluentAssertions extensions shipped with Mockly.
- If you disabled HttpMock.PrefetchBody, RequestInfo.Body will be null; enable it when you need to assert on the body.
By default, unexpected requests throw an exception:
var mock = new HttpMock();
mock.FailOnUnexpectedCalls = true; // Default
// This will throw UnexpectedRequestException
await client.GetAsync("http://localhost/unmocked-path");To allow unexpected requests:
mock.FailOnUnexpectedCalls = false;
// Returns 404 NotFound instead of throwing
var response = await client.GetAsync("http://localhost/unmocked-path");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);You can continue adding mocks to an existing HttpMock:
var mock = new HttpMock();
// Initial configuration
mock.ForGet().WithPath("/api/users").RespondsWithStatus(HttpStatusCode.OK);
var client = mock.GetClient();
// ... make some requests ...
// Add more mocks later
mock.ForPost().WithPath("/api/users").RespondsWithStatus(HttpStatusCode.Created);
Continue building can reuse parts of the previous builder for convenience. You can opt out with `Reset()`:
```csharp
mock.ForGet()
.ForHttps().ForHost("somehost")
.WithPath("/api/test")
.WithQuery("?q=test")
.RespondsWithStatus(HttpStatusCode.OK);
// Reset prevents reusing the previous builder's scheme/host
mock.Reset();
mock.ForGet()
.WithPath("/api/test")
.WithQuery("?q=test")
.RespondsWithStatus(HttpStatusCode.NotModified);To remove all configured mocks entirely, use Clear():
mock.Clear();Produce OData-style envelopes directly from the builder:
var items = new[] { new { Id = 1, Name = "A" }, new { Id = 2, Name = "B" } };
mock.ForGet()
.WithPath("/odata/items")
.RespondsWithODataResult(items);
// Empty result
mock.ForGet()
.WithPath("/odata/empty")
.RespondsWithODataResult(Array.Empty<object>());
// Include @odata.context and custom status code
mock.ForGet()
.WithPath("/odata/ctx")
.RespondsWithODataResult(items, context: "http://localhost/$metadata#items", statusCode: HttpStatusCode.OK);// Same client works with new mocks await client.PostAsync("http://localhost/api/users", content);
### Clearing Mocks
Reset all configured mocks:
```csharp
mock.Clear();
Here's a comprehensive example showing multiple features:
using Mockly;
using FluentAssertions;
public class UserServiceTests
{
[Fact]
public async Task Should_Handle_User_Operations()
{
// Arrange
var mock = new HttpMock();
var capturedPatches = new RequestCollection();
// Configure mocks
mock.ForGet()
.WithPath("/api/users/*")
.RespondsWithJsonContent(new { Id = 123, Name = "John Doe" });
mock.ForPost()
.WithPath("/api/users")
.RespondsWithStatus(HttpStatusCode.Created);
mock.ForPatch()
.WithPath("/api/users/*")
.CollectingRequestIn(capturedPatches)
.RespondsWithStatus(HttpStatusCode.NoContent);
HttpClient client = mock.GetClient();
var service = new UserService(client);
// Act
var user = await service.GetUserAsync(123);
await service.CreateUserAsync(new { Name = "Jane" });
await service.UpdateUserAsync(123, new { Name = "John Updated" });
// Assert
user.Name.Should().Be("John Doe");
mock.Should().HaveAllRequestsCalled();
mock.Requests.Should().HaveCount(3);
mock.Requests.Should().NotContainUnexpectedCalls();
capturedPatches.Count.Should().Be(1);
capturedPatches.First().Should().BeExpected();
}
}This library is available as a NuGet package on https://nuget.org. To install it, use the following command-line:
dotnet add package mocklyOr via the Package Manager Console in Visual Studio:
Install-Package mocklyTo build this repository locally, you need the following:
- The .NET SDKs for .NET 4.7 and 8.0.
- Visual Studio, JetBrains Rider or Visual Studio Code with the C# DevKit
You can also build, run the unit tests and package the code using the following command-line:
build.ps1Or, if you have the Nuke tool installed:
nukeAlso try using --help to see all the available options or --plan to see what the scripts does.
Your contributions are always welcome! Please have a look at the contribution guidelines first.
Previous contributors include:
(Made with contrib.rocks)
This library uses Semantic Versioning to give meaning to the version numbers. For the versions available, see the releases on this repository.
This library wouldn't have been possible without the following tools, packages and companies:
- FluentAssertions - Fluent API for asserting the results of unit tests by Dennis Doomen
- Nuke - Smart automation for DevOps teams and CI/CD pipelines by Matthias Koch
- xUnit - Community-focused unit testing tool for .NET by Brad Wilson
- Coverlet - Cross platform code coverage for .NET by Toni Solarin-Sodara
- GitVersion - From git log to SemVer in no time
- ReportGenerator - Converts coverage reports by Daniel Palme
- StyleCopyAnalyzer - StyleCop rules for .NET
- Roslynator - A set of code analysis tools for C# by Josef Pihrt
- CSharpCodingGuidelines - Roslyn analyzers by Bart Koelman to go with the C# Coding Guidelines
- Meziantou - Another set of awesome Roslyn analyzers by Gérald Barré
You may also be interested in:
- FluentAssertions - The assertion library that Mockly integrates with
- PackageGuard - Get a grip on your open-source packages
- Reflectify - Reflection extensions without causing dependency pains
- Pathy - Fluently building and using file and directory paths without binary dependencies
- .NET Library Starter Kit - A battle-tested starter kit for building open-source and internal NuGet libraries
This project is licensed under the MIT License - see the LICENSE file for details.
