Skip to content

dennisdoomen/mockly

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 


PackageGuard

Fluent HTTP mocking for .NET like it should have done

About

What's this?

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

What's so special about that?

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(), or Times(n)

Who created this?

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.

Key Features

🎯 Fluent Request Matching

mock.ForGet().WithPath("/api/users/*").RespondsWithJsonContent(user);
mock.ForPost().WithPath("/api/data").WithQuery("?filter=*").RespondsWithStatus(HttpStatusCode.Created);

📃 Clear Reporting

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)

🔍 Request Capture & Inspection

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");

✅ Powerful Assertions

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);

🎨 Multiple Response Types

  • JSON content with automatic serialization
  • Raw string content
  • Empty responses
  • Custom HTTP status codes
  • Custom response generators

🛡️ Fail-Fast Testing

mock.FailOnUnexpectedCalls = true; // Default behavior
// Throws UnexpectedRequestException if an unmocked request is made

Quick Start

Install the package:

dotnet add package mockly

To 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.v8

Basic 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();

Usage

Basic Mocking

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();

HTTP Method Support

mock.ForGet()     // GET requests
mock.ForPost()    // POST requests
mock.ForPut()     // PUT requests
mock.ForPatch()   // PATCH requests
mock.ForDelete()  // DELETE requests

Path and Query Matching

Exact 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");

Response Configuration

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;
    });

Advanced Features

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 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).

Body Matching

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.

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 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.

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. 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 FailOnUnexpectedCalls is true (default), an UnexpectedRequestException is thrown.
  • The default for mocks without limits is unlimited invocations; behavior is unchanged from earlier versions.
  • The verification helpers consider limits:
    • HttpMock.AllMocksInvoked returns true only when each mock has been called at least once or has reached its configured Times(..) limit.
    • HttpMock.GetUninvokedMocks() lists mocks that haven’t reached their required count (or have 0 calls for unlimited mocks).

Request Collection

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();

Request Inspection

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));

Assertions

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.

Handling Unexpected Requests

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);

Continuing Configuration and Builder Lifecycle

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();

OData Result Helpers

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();

Complete Example

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();
    }
}

Download

This library is available as a NuGet package on https://nuget.org. To install it, use the following command-line:

dotnet add package mockly

Or via the Package Manager Console in Visual Studio:

Install-Package mockly

Building

To 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.ps1

Or, if you have the Nuke tool installed:

nuke

Also try using --help to see all the available options or --plan to see what the scripts does.

Contributing

Your contributions are always welcome! Please have a look at the contribution guidelines first.

Previous contributors include:

contrib.rocks image

(Made with contrib.rocks)

Versioning

This library uses Semantic Versioning to give meaning to the version numbers. For the versions available, see the releases on this repository.

Credits

This library wouldn't have been possible without the following tools, packages and companies:

Related Projects

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

License

This project is licensed under the MIT License - see the LICENSE file for details.

About

Fluent HTTP mocking that works

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Sponsor this project

Packages

No packages published