Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .agents/skills/mockly/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
name: mockly
description: >
Helps write tests using Mockly — a fluent HTTP mocking library for .NET that intercepts HttpClient
calls. Use this skill when writing unit or integration tests that need to mock HTTP requests,
configure responses (status codes, JSON, OData, raw content), capture and inspect requests,
limit mock invocations, or assert HTTP interactions with FluentAssertions.
---

# Mockly

Fluent HTTP mocking library for .NET. Intercepts `HttpClient` calls via a custom `HttpMessageHandler`.
Unmatched requests throw `UnexpectedRequestException` by default (`FailOnUnexpectedCalls = true`).
Default base address: `https://localhost/`.

## Setup

```csharp
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users").RespondsWithStatus(HttpStatusCode.OK);
HttpClient client = mock.GetClient(); // or mock.GetClientFactory()
```

## URL Matching

```csharp
// Exact path (leading slash optional); * is wildcard
mock.ForGet().WithPath("/api/users/*").RespondsWithStatus(HttpStatusCode.OK);

// Query: omitting WithQuery() rejects requests that include a query string
mock.ForGet().WithPath("/api/search").WithQuery("?q=*").RespondsWithStatus(HttpStatusCode.OK);
mock.ForGet().WithPath("/api/search").WithAnyQuery().RespondsWithStatus(HttpStatusCode.OK);
mock.ForGet().WithPath("/api/search").WithoutQuery().RespondsWithStatus(HttpStatusCode.OK);

// Scheme / host
mock.ForGet().ForHttps().ForHost("api.example.com").WithPath("/data").RespondsWithStatus(HttpStatusCode.OK);
mock.ForGet().ForHttp().ForAnyHost().WithPath("/data").RespondsWithStatus(HttpStatusCode.OK);

// Full-URL shorthand (scheme + host + path + query, all support *)
mock.ForGet("https://api.example.com/users/*?q=*").RespondsWithStatus(HttpStatusCode.OK);
mock.ForPatch("http://*.example.com/*").RespondsWithStatus(HttpStatusCode.OK);
```

## Request Body Matching

```csharp
mock.ForPost().WithPath("/api/data").WithBody("*keyword*").RespondsWithStatus(HttpStatusCode.NoContent);
mock.ForPost().WithPath("/api/data").WithBody(new { name = "John" }).RespondsWithStatus(HttpStatusCode.NoContent); // JSON object
mock.ForPost().WithPath("/api/data").WithBodyMatchingJson("{\"name\":\"John\"}").RespondsWithStatus(HttpStatusCode.NoContent);
mock.ForPost().WithPath("/api/data").WithBodyMatchingRegex(".*keyword.*").RespondsWithStatus(HttpStatusCode.NoContent);

// Custom predicate (sync or async) — also use .With() for header/URI checks
mock.ForPost().WithPath("/api/data").With(req => req.Body!.Contains("keyword")).RespondsWithStatus(HttpStatusCode.NoContent);
mock.ForGet().WithPath("/api/secure").With(req => req.Headers.Contains("X-API-Key")).RespondsWithStatus(HttpStatusCode.OK);
```

## Responses

```csharp
mock.ForDelete().WithPath("/api/items/1").RespondsWithStatus(HttpStatusCode.NoContent);
mock.ForGet().WithPath("/api/user").RespondsWithJsonContent(new { Id = 1, Name = "Alice" });
mock.ForGet().WithPath("/api/user").RespondsWithJsonContent(HttpStatusCode.Created, new { Id = 1 });
mock.ForGet().WithPath("/api/text").RespondsWithContent("Hello"); // 200 OK, application/json
mock.ForGet().WithPath("/api/text").RespondsWithContent(HttpStatusCode.OK, "<xml/>", "text/xml");
mock.ForGet().WithPath("/api/empty").RespondsWithEmptyContent(); // 204 No Content
mock.ForGet().WithPath("/api/binary").RespondsWith(new ByteArrayContent(bytes));
mock.ForGet().WithPath("/api/custom").RespondsWith(_ => new HttpResponseMessage(HttpStatusCode.OK));

// OData v4: wraps in { "value": [...] }
mock.ForGet().WithPath("/odata/items").RespondsWithODataResult(new { Id = 1 });
mock.ForGet().WithPath("/odata/items").RespondsWithODataResult(HttpStatusCode.OK, items, "https://localhost/$metadata#Items");
```

> `HttpContent` instances are reused across calls — use the lambda overload for mocks called multiple times.

## Request Capture

```csharp
// Global log
mock.Requests.HasUnexpectedRequests // bool
mock.Requests.First().WasExpected // bool; .Uri, .Method, .Body, .Timestamp, .Response also available

// Scoped collection
var captured = new RequestCollection();
mock.ForPatch().WithPath("/api/items/1").CollectingRequestsIn(captured).RespondsWithStatus(HttpStatusCode.NoContent);
captured.Count.Should().Be(1);
```

## Invocation Limits

```csharp
mock.ForGet().WithPath("/api/once").RespondsWithStatus(HttpStatusCode.OK).Once();
mock.ForGet().WithPath("/api/twice").RespondsWithStatus(HttpStatusCode.OK).Twice();
mock.ForGet().WithPath("/api/data").RespondsWithStatus(HttpStatusCode.OK).Times(3);

mock.AllMocksInvoked.Should().BeTrue(); // true when all mocks hit required count
mock.GetUninvokedMocks(); // IEnumerable<RequestMock>
```

## Response Latency

```csharp
// Delay the response to test timeout / cancellation / resilience behavior.
mock.ForGet().WithPath("/slow").RespondsWithStatus(HttpStatusCode.OK).After(TimeSpan.FromSeconds(2));
// A shorter HttpClient.Timeout or a cancelled token throws TaskCanceledException/OperationCanceledException.
```

## Reset / Clear

```csharp
mock.Reset(); // clears scheme/host inheritance from previous builder; start fresh
mock.Clear(); // removes all configured mocks
```

## FluentAssertions.Mockly (`FluentAssertions.Mockly.v8` / `.v7`)

```csharp
mock.Should().HaveAllRequestsCalled();
mock.Requests.Should().NotContainUnexpectedCalls();
mock.Requests.Should().NotBeEmpty();
mock.Requests.First().Should().BeExpected();
mock.Requests.First().Should().BeUnexpected();
```
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
namespace Mockly
{
public class CapturedRequestAssertions : FluentAssertions.Primitives.ObjectAssertions<Mockly.CapturedRequest, Mockly.CapturedRequestAssertions>
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeUnexpected(string because = "", params object[] becauseArgs) { }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
namespace Mockly
{
public class CapturedRequestAssertions : FluentAssertions.Primitives.ObjectAssertions<Mockly.CapturedRequest, Mockly.CapturedRequestAssertions>
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeUnexpected(string because = "", params object[] becauseArgs) { }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
namespace Mockly
{
public class CapturedRequestAssertions : FluentAssertions.Primitives.ObjectAssertions<Mockly.CapturedRequest, Mockly.CapturedRequestAssertions>
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeUnexpected(string because = "", params object[] becauseArgs) { }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/dennisdoomen/fluentassertions.mockly")]
namespace Mockly
{
public class CapturedRequestAssertions : FluentAssertions.Primitives.ObjectAssertions<Mockly.CapturedRequest, Mockly.CapturedRequestAssertions>
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint<Mockly.CapturedRequestAssertions> BeUnexpected(string because = "", params object[] becauseArgs) { }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="7.1.0" />
<PackageReference Include="Mockly" Version="[1.4.0,2.0.0)" />
<PackageReference Include="Mockly" Version="[1.8.0-preview,2.0.0)" />
<PackageReference Include="System.Net.Http" Version="4.3.4"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="xunit" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

<ItemGroup Label="Dependencies">
<PackageReference Include="FluentAssertions" Version="[7.1.0, 8.0.0)" />
<PackageReference Include="Mockly" Version="[1.4.0,2.0.0)" />
<PackageReference Include="Mockly" Version="[1.8.0-preview,2.0.0)" />
</ItemGroup>

<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Mockly" Version="[1.4.0,2.0.0)" />
<PackageReference Include="Mockly" Version="[1.8.0-preview,2.0.0)" />
<PackageReference Include="System.Net.Http" Version="4.3.4"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="xunit" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

<ItemGroup Label="Dependencies">
<PackageReference Include="FluentAssertions" Version="[8.0.0,9.0.0)" />
<PackageReference Include="Mockly" Version="[1.4.0,2.0.0)" />
<PackageReference Include="Mockly" Version="[1.8.0-preview,2.0.0)" />
</ItemGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'net472' ">
Expand Down
26 changes: 26 additions & 0 deletions Shared/HttpMockAssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,32 @@ public AndConstraint<CapturedRequestAssertions> BeUnexpected(string because = ""
return new AndConstraint<CapturedRequestAssertions>(this);
}

/// <summary>
/// Asserts that the captured request entry represents a simulated network failure, i.e., a mock configured
/// with <c>ThrowsException</c> or <c>TimesOut</c> caused a transport-level exception to be propagated to the
/// <see cref="System.Net.Http.HttpClient"/> caller instead of returning an HTTP response.
/// </summary>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <paramref name="because" />.
/// </param>
public AndConstraint<CapturedRequestAssertions> BeASimulatedFailure(string because = "", params object[] becauseArgs)
{
#if FA8
AssertionChain.GetOrCreate()
#else
Execute.Assertion
#endif
.BecauseOf(because, becauseArgs)
.ForCondition(subject.SimulatedFailure is not null)
.FailWith("request should be a simulated failure, but no simulated failure was recorded");

return new AndConstraint<CapturedRequestAssertions>(this);
}

protected override string Identifier
{
get => "request";
Expand Down
53 changes: 53 additions & 0 deletions Specs/AssertionSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -857,4 +857,57 @@ public async Task Works_for_contained_request_assertions()
.WithBodyHavingProperty("id", "1");
}
}

public class SimulatedFailureAssertionSpecs
{
[Fact]
public async Task Can_assert_captured_request_is_a_simulated_failure_using_throws_exception()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/flaky").ThrowsException<HttpRequestException>();
var client = mock.GetClient();

// Act
await client.Invoking(c => c.GetAsync("https://localhost/flaky")).Should().ThrowAsync<HttpRequestException>();

// Assert
var request = mock.Requests.First();
request.Should().BeASimulatedFailure();
}

[Fact]
public async Task Can_assert_captured_request_is_a_simulated_failure_using_times_out()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/slow").TimesOut();
var client = mock.GetClient();

// Act
await client.Invoking(c => c.GetAsync("https://localhost/slow")).Should().ThrowAsync<TaskCanceledException>();

// Assert
var request = mock.Requests.First();
request.Should().BeASimulatedFailure();
}

[Fact]
public async Task Will_throw_when_captured_request_is_not_a_simulated_failure()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/test").RespondsWithStatus(HttpStatusCode.OK);
var client = mock.GetClient();

// Act
await client.GetAsync("https://localhost/api/test");
var request = mock.Requests.First();
var act = () => request.Should().BeASimulatedFailure();

// Assert
act.Should().Throw<XunitException>()
.WithMessage("request should be a simulated failure, but no simulated failure was recorded");
}
}
}
Loading