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