diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt index 4cff975..645ebfc 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt @@ -16,6 +16,7 @@ namespace Mockly public System.Net.Http.HttpResponseMessage Response { get; } public string Scheme { get; } public int Sequence { get; set; } + public System.Exception? SimulatedFailure { get; } public System.DateTime Timestamp { get; } public System.Uri? Uri { get; } public System.Version Version { get; } @@ -129,6 +130,10 @@ namespace Mockly public Mockly.RequestMockResponseBuilder RespondsWithODataResult(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable> builders) { } public Mockly.RequestMockResponseBuilder RespondsWithODataResult(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable> builders, string odataContext) { } public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { } + public Mockly.RequestMockResponseBuilder ThrowsException(System.Exception exception) { } + public Mockly.RequestMockResponseBuilder ThrowsException() + where TException : System.Exception, new () { } + public Mockly.RequestMockResponseBuilder TimesOut() { } public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { } public Mockly.RequestMockBuilder With(System.Func> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } public Mockly.RequestMockBuilder With(System.Func matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt index e769c98..988aa40 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt @@ -17,6 +17,7 @@ namespace Mockly public System.Net.Http.HttpResponseMessage Response { get; } public string Scheme { get; } public int Sequence { get; set; } + public System.Exception? SimulatedFailure { get; } public System.DateTime Timestamp { get; } public System.Uri? Uri { get; } public System.Version Version { get; } @@ -132,6 +133,10 @@ namespace Mockly public Mockly.RequestMockResponseBuilder RespondsWithODataResult(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable> builders) { } public Mockly.RequestMockResponseBuilder RespondsWithODataResult(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable> builders, string odataContext) { } public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { } + public Mockly.RequestMockResponseBuilder ThrowsException(System.Exception exception) { } + public Mockly.RequestMockResponseBuilder ThrowsException() + where TException : System.Exception, new () { } + public Mockly.RequestMockResponseBuilder TimesOut() { } public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { } public Mockly.RequestMockBuilder With(System.Func> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } public Mockly.RequestMockBuilder With(System.Func matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } diff --git a/Mockly.Specs/HttpMockSpecs.cs b/Mockly.Specs/HttpMockSpecs.cs index eba6eac..6fe35f9 100644 --- a/Mockly.Specs/HttpMockSpecs.cs +++ b/Mockly.Specs/HttpMockSpecs.cs @@ -2523,4 +2523,164 @@ private class TestData #endif } + public class WhenSimulatingNetworkFailures + { + [Fact] + public async Task A_thrown_transport_exception_surfaces_to_the_caller() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/flaky").ThrowsException(); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/flaky"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task A_specific_exception_instance_is_thrown_to_the_caller() + { + // Arrange + var mock = new HttpMock(); + var failure = new HttpRequestException("connection reset"); + mock.ForGet().WithPath("/flaky").ThrowsException(failure); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/flaky"); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("connection reset"); + } + + [Fact] + public async Task A_null_exception_is_rejected() + { + // Arrange + var mock = new HttpMock(); + + // Act + Action act = () => mock.ForGet().WithPath("/flaky").ThrowsException(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public async Task Timing_out_throws_a_task_cancelled_exception() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/slow").TimesOut(); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/slow"); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task The_failed_call_is_still_captured_in_the_requests() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/flaky").ThrowsException(); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/flaky"); + await act.Should().ThrowAsync(); + + // Assert + mock.Requests.Should().ContainSingle(); + CapturedRequest captured = mock.Requests.Single(); + captured.WasExpected.Should().BeTrue(); + captured.Path.Should().Be("/flaky"); + captured.SimulatedFailure.Should().BeOfType(); + } + + [Fact] + public async Task The_failed_call_is_collected_in_a_request_collection() + { + // Arrange + var mock = new HttpMock(); + var collected = new RequestCollection(); + mock.ForGet().WithPath("/flaky").CollectingRequestsIn(collected).ThrowsException(); + + var client = mock.GetClient(); + + // Act + Func act = () => client.GetAsync("https://localhost/flaky"); + await act.Should().ThrowAsync(); + + // Assert + collected.Should().ContainSingle(); + collected.Single().SimulatedFailure.Should().BeOfType(); + } + + [Fact] + public async Task A_simulated_failure_limited_to_once_only_applies_to_the_first_call() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/flaky").ThrowsException().Once(); + + var client = mock.GetClient(); + + // Act + Func firstCall = () => client.GetAsync("https://localhost/flaky"); + await firstCall.Should().ThrowAsync(); + + // Assert: the second call no longer matches the exhausted mock + Func secondCall = () => client.GetAsync("https://localhost/flaky"); + await secondCall.Should().ThrowAsync(); + } + + [Fact] + public async Task A_simulated_failure_limited_to_a_number_of_times_applies_to_each_call() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/flaky").ThrowsException().Times(2); + + var client = mock.GetClient(); + + // Act + Assert: both configured calls fail as simulated failures + Func firstCall = () => client.GetAsync("https://localhost/flaky"); + await firstCall.Should().ThrowAsync(); + + Func secondCall = () => client.GetAsync("https://localhost/flaky"); + await secondCall.Should().ThrowAsync(); + + mock.AllMocksInvoked.Should().BeTrue(); + } + + [Fact] + public async Task A_buggy_responder_still_results_in_a_500_response() + { + // Arrange + var mock = new HttpMock(); + mock.ForGet().WithPath("/bug").RespondsWith(new Func( + _ => throw new InvalidOperationException("boom"))); + + var client = mock.GetClient(); + + // Act + var response = await client.GetAsync("https://localhost/bug"); + + // Assert: genuine responder bugs keep the existing 500 behavior rather than propagating + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + } + } diff --git a/Mockly/CapturedRequest.cs b/Mockly/CapturedRequest.cs index fb2a9ee..78acb46 100644 --- a/Mockly/CapturedRequest.cs +++ b/Mockly/CapturedRequest.cs @@ -13,6 +13,18 @@ public class CapturedRequest(RequestInfo request) /// public HttpResponseMessage Response { get; internal set; } = new(); + /// + /// Gets the exception that was thrown to simulate a network-level failure for this request, or null + /// when the request produced a regular response. + /// + /// + /// This is set when the matching mock was configured with one of the simulated-failure methods such as + /// ThrowsException or TimesOut. When set, the HTTP pipeline propagates this exception to the + /// caller, and does not represent a response that was actually + /// returned to the caller. + /// + public Exception? SimulatedFailure { get; internal set; } + /// /// The mock that handled this request, if any. /// diff --git a/Mockly/HttpMock.cs b/Mockly/HttpMock.cs index a8b38e7..27a1825 100644 --- a/Mockly/HttpMock.cs +++ b/Mockly/HttpMock.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Runtime.ExceptionServices; using System.Text; using Mockly.Common; #if NET472_OR_GREATER @@ -269,6 +270,11 @@ private async Task HandleRequest(HttpRequestMessage httpReq Requests.Add(capturedRequest); + if (capturedRequest.SimulatedFailure is not null) + { + ExceptionDispatchInfo.Capture(capturedRequest.SimulatedFailure).Throw(); + } + if (!foundMatch && FailOnUnexpectedCalls) { await ThrowDetailedException(request); diff --git a/Mockly/RequestMock.cs b/Mockly/RequestMock.cs index 28e45de..1ab65ca 100644 --- a/Mockly/RequestMock.cs +++ b/Mockly/RequestMock.cs @@ -48,6 +48,13 @@ public class RequestMock /// internal TimeSpan? Delay { get; set; } + /// + /// Gets the responder that represents an intentionally simulated transport failure, if configured. + /// When set, the asynchronous response path records the request and then signals the HTTP pipeline to + /// propagate the produced exception to the caller instead of converting it into a 500 response. + /// + internal SimulatedFailureResponder? SimulatedFailure { get; init; } + public RequestCollection? RequestCollection { get; init; } = []; /// @@ -284,6 +291,14 @@ public CapturedRequest TrackRequest(RequestInfo request) Timestamp = DateTime.UtcNow }; + if (SimulatedFailure is not null) + { + capturedRequest.SimulatedFailure = SimulatedFailure.CreateException(); + RequestCollection?.Add(capturedRequest); + + return capturedRequest; + } + try { capturedRequest.Response = Responder(request); @@ -321,6 +336,16 @@ internal async Task TrackRequestAsync(RequestInfo request, Canc Timestamp = DateTime.UtcNow }; + if (SimulatedFailure is not null) + { + await ApplyConfiguredDelayAsync(cancellationToken); + + capturedRequest.SimulatedFailure = SimulatedFailure.CreateException(); + RequestCollection?.Add(capturedRequest); + + return capturedRequest; + } + try { capturedRequest.Response = await InvokeResponderAsync(request, cancellationToken); @@ -350,16 +375,24 @@ internal async Task TrackRequestAsync(RequestInfo request, Canc /// private async Task InvokeResponderAsync(RequestInfo request, CancellationToken cancellationToken) { - if (Delay is { } delay && delay > TimeSpan.Zero) - { - await Task.Delay(delay, cancellationToken); - } + await ApplyConfiguredDelayAsync(cancellationToken); return AsyncResponder is not null ? await AsyncResponder(request, cancellationToken) : Responder(request); } + /// + /// Awaits the configured (if any), honoring the supplied . + /// + private async Task ApplyConfiguredDelayAsync(CancellationToken cancellationToken) + { + if (Delay is { } delay && delay > TimeSpan.Zero) + { + await Task.Delay(delay, cancellationToken); + } + } + /// /// Builds a detailed textual representation of this mock, including its route /// and any configured custom matchers. diff --git a/Mockly/RequestMockBuilder.cs b/Mockly/RequestMockBuilder.cs index 9adb0b2..56e0b30 100644 --- a/Mockly/RequestMockBuilder.cs +++ b/Mockly/RequestMockBuilder.cs @@ -672,4 +672,75 @@ public RequestMockResponseBuilder RespondsWith(Func + /// Configures the mock to simulate a network-level failure by throwing the specified + /// instead of returning a response. + /// + /// + /// The exception is propagated to the caller rather than being converted + /// into a 500 Internal Server Error response, allowing tests to verify retry, circuit-breaker and other + /// resilience behavior. The matching request is still recorded in before the + /// exception is thrown. The same exception instance is thrown on every matching invocation. + /// + /// The exception to throw for a matching request. + /// Thrown when is null. + public RequestMockResponseBuilder ThrowsException(Exception exception) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + return RespondsWithSimulatedFailure(() => exception); + } + + /// + /// Configures the mock to simulate a network-level failure by throwing a freshly constructed + /// instead of returning a response. + /// + /// + /// A new instance of is created for every matching invocation. The exception is + /// propagated to the caller rather than being converted into a + /// 500 Internal Server Error response. The matching request is still recorded in + /// before the exception is thrown. + /// + /// The type of exception to throw for a matching request. + public RequestMockResponseBuilder ThrowsException() + where TException : Exception, new() + { + return RespondsWithSimulatedFailure(static () => new TException()); + } + + /// + /// Configures the mock to simulate an timeout by throwing a + /// instead of returning a response. + /// + /// + /// This mimics the behavior of a real whose request exceeds its + /// configured timeout. The exception is propagated to the caller and the matching request is still recorded in + /// before the exception is thrown. + /// + public RequestMockResponseBuilder TimesOut() + { + return RespondsWithSimulatedFailure(static () => new TaskCanceledException()); + } + + private RequestMockResponseBuilder RespondsWithSimulatedFailure(Func exceptionFactory) + { + var mock = new RequestMock + { + Method = Method, + PathPattern = pathPattern, + QueryPattern = queryPattern, + Scheme = scheme, + HostPattern = hostPattern, + CustomMatchers = customMatchers, + RequestCollection = requestCollection, + SimulatedFailure = new SimulatedFailureResponder(exceptionFactory) + }; + + mockBuilder.AddMock(mock); + return new RequestMockResponseBuilder(mock); + } } diff --git a/Mockly/SimulatedFailureResponder.cs b/Mockly/SimulatedFailureResponder.cs new file mode 100644 index 0000000..5e81cc0 --- /dev/null +++ b/Mockly/SimulatedFailureResponder.cs @@ -0,0 +1,25 @@ +namespace Mockly; + +/// +/// Dedicated responder that represents an intentionally simulated transport failure. +/// +/// +/// Unlike a regular responder, the exception produced by this responder is propagated to the +/// caller by the HTTP pipeline instead of being turned into a +/// 500 Internal Server Error response. This allows tests to verify retry, circuit-breaker and other +/// resilience behavior. +/// +internal sealed class SimulatedFailureResponder +{ + private readonly Func exceptionFactory; + + public SimulatedFailureResponder(Func exceptionFactory) + { + this.exceptionFactory = exceptionFactory; + } + + /// + /// Creates the exception to throw for a matching request. + /// + public Exception CreateException() => exceptionFactory(); +}