diff --git a/.agents/skills/mockly/SKILL.md b/.agents/skills/mockly/SKILL.md
new file mode 100644
index 0000000..675956d
--- /dev/null
+++ b/.agents/skills/mockly/SKILL.md
@@ -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, "", "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
+```
+
+## 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();
+```
diff --git a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net472.verified.txt b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net472.verified.txt
index aabefa5..c20fe3b 100644
--- a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net472.verified.txt
+++ b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net472.verified.txt
@@ -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
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
+ public FluentAssertions.AndConstraint BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeUnexpected(string because = "", params object[] becauseArgs) { }
}
diff --git a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net8.0.verified.txt b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net8.0.verified.txt
index aabefa5..c20fe3b 100644
--- a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net8.0.verified.txt
+++ b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v7.net8.0.verified.txt
@@ -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
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
+ public FluentAssertions.AndConstraint BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeUnexpected(string because = "", params object[] becauseArgs) { }
}
diff --git a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net472.verified.txt b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net472.verified.txt
index aabefa5..c20fe3b 100644
--- a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net472.verified.txt
+++ b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net472.verified.txt
@@ -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
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
+ public FluentAssertions.AndConstraint BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeUnexpected(string because = "", params object[] becauseArgs) { }
}
diff --git a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net8.0.verified.txt b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net8.0.verified.txt
index aabefa5..c20fe3b 100644
--- a/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net8.0.verified.txt
+++ b/FluentAssertions.Mockly.ApiVerificationTests/ApprovedApi/FluentAssertions.Mockly.v8.net8.0.verified.txt
@@ -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
{
public CapturedRequestAssertions(Mockly.CapturedRequest subject) { }
protected override string Identifier { get; }
+ public FluentAssertions.AndConstraint BeASimulatedFailure(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeExpected(string because = "", params object[] becauseArgs) { }
public FluentAssertions.AndConstraint BeUnexpected(string because = "", params object[] becauseArgs) { }
}
diff --git a/FluentAssertions.Mockly.v7.Specs/FluentAssertions.Mockly.v7.Specs.csproj b/FluentAssertions.Mockly.v7.Specs/FluentAssertions.Mockly.v7.Specs.csproj
index a46abb2..e9e3aaf 100644
--- a/FluentAssertions.Mockly.v7.Specs/FluentAssertions.Mockly.v7.Specs.csproj
+++ b/FluentAssertions.Mockly.v7.Specs/FluentAssertions.Mockly.v7.Specs.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/FluentAssertions.Mockly.v7/FluentAssertions.Mockly.v7.csproj b/FluentAssertions.Mockly.v7/FluentAssertions.Mockly.v7.csproj
index b5124e3..a560518 100644
--- a/FluentAssertions.Mockly.v7/FluentAssertions.Mockly.v7.csproj
+++ b/FluentAssertions.Mockly.v7/FluentAssertions.Mockly.v7.csproj
@@ -44,7 +44,7 @@
-
+
diff --git a/FluentAssertions.Mockly.v8.Specs/FluentAssertions.Mockly.v8.Specs.csproj b/FluentAssertions.Mockly.v8.Specs/FluentAssertions.Mockly.v8.Specs.csproj
index 0ae8e40..9d9b45c 100644
--- a/FluentAssertions.Mockly.v8.Specs/FluentAssertions.Mockly.v8.Specs.csproj
+++ b/FluentAssertions.Mockly.v8.Specs/FluentAssertions.Mockly.v8.Specs.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/FluentAssertions.Mockly.v8/FluentAssertions.Mockly.v8.csproj b/FluentAssertions.Mockly.v8/FluentAssertions.Mockly.v8.csproj
index 9ffe2ee..7d71724 100644
--- a/FluentAssertions.Mockly.v8/FluentAssertions.Mockly.v8.csproj
+++ b/FluentAssertions.Mockly.v8/FluentAssertions.Mockly.v8.csproj
@@ -44,7 +44,7 @@
-
+
diff --git a/Shared/HttpMockAssertionExtensions.cs b/Shared/HttpMockAssertionExtensions.cs
index a010bae..9901017 100644
--- a/Shared/HttpMockAssertionExtensions.cs
+++ b/Shared/HttpMockAssertionExtensions.cs
@@ -329,6 +329,32 @@ public AndConstraint BeUnexpected(string because = ""
return new AndConstraint(this);
}
+ ///
+ /// Asserts that the captured request entry represents a simulated network failure, i.e., a mock configured
+ /// with ThrowsException or TimesOut caused a transport-level exception to be propagated to the
+ /// caller instead of returning an HTTP response.
+ ///
+ ///
+ /// A formatted phrase as is supported by explaining why the assertion
+ /// is needed. If the phrase does not start with the word because, it is prepended automatically.
+ ///
+ ///
+ /// Zero or more objects to format using the placeholders in .
+ ///
+ public AndConstraint 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(this);
+ }
+
protected override string Identifier
{
get => "request";
diff --git a/Specs/AssertionSpecs.cs b/Specs/AssertionSpecs.cs
index 9555195..cad5a63 100644
--- a/Specs/AssertionSpecs.cs
+++ b/Specs/AssertionSpecs.cs
@@ -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();
+ var client = mock.GetClient();
+
+ // Act
+ await client.Invoking(c => c.GetAsync("https://localhost/flaky")).Should().ThrowAsync();
+
+ // 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();
+
+ // 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()
+ .WithMessage("request should be a simulated failure, but no simulated failure was recorded");
+ }
+ }
}