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
5 changes: 5 additions & 0 deletions Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -129,6 +130,10 @@ namespace Mockly
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders, string odataContext) { }
public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.RequestMockResponseBuilder ThrowsException(System.Exception exception) { }
public Mockly.RequestMockResponseBuilder ThrowsException<TException>()
where TException : System.Exception, new () { }
public Mockly.RequestMockResponseBuilder TimesOut() { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
5 changes: 5 additions & 0 deletions Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -132,6 +133,10 @@ namespace Mockly
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders, string odataContext) { }
public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.RequestMockResponseBuilder ThrowsException(System.Exception exception) { }
public Mockly.RequestMockResponseBuilder ThrowsException<TException>()
where TException : System.Exception, new () { }
public Mockly.RequestMockResponseBuilder TimesOut() { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
160 changes: 160 additions & 0 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2523,4 +2523,164 @@
#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<HttpRequestException>();

var client = mock.GetClient();

// Act
Func<Task> act = () => client.GetAsync("https://localhost/flaky");

// Assert
await act.Should().ThrowAsync<HttpRequestException>();
}

[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<Task> act = () => client.GetAsync("https://localhost/flaky");

// Assert
(await act.Should().ThrowAsync<HttpRequestException>())
.Which.Message.Should().Be("connection reset");
}

[Fact]
public async Task A_null_exception_is_rejected()

Check failure

Code scanning / InspectCode

Async function without await expression Error

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
// Arrange
var mock = new HttpMock();

// Act
Action act = () => mock.ForGet().WithPath("/flaky").ThrowsException(null!);

// Assert
act.Should().Throw<ArgumentNullException>();
}

[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<Task> act = () => client.GetAsync("https://localhost/slow");

// Assert
await act.Should().ThrowAsync<TaskCanceledException>();
}

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

var client = mock.GetClient();

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

// Assert
mock.Requests.Should().ContainSingle();
CapturedRequest captured = mock.Requests.Single();
captured.WasExpected.Should().BeTrue();
captured.Path.Should().Be("/flaky");
captured.SimulatedFailure.Should().BeOfType<HttpRequestException>();
}

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

var client = mock.GetClient();

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

// Assert
collected.Should().ContainSingle();
collected.Single().SimulatedFailure.Should().BeOfType<HttpRequestException>();
}

[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<HttpRequestException>().Once();

var client = mock.GetClient();

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

// Assert: the second call no longer matches the exhausted mock
Func<Task> secondCall = () => client.GetAsync("https://localhost/flaky");
await secondCall.Should().ThrowAsync<UnexpectedRequestException>();
}

[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<HttpRequestException>().Times(2);

var client = mock.GetClient();

// Act + Assert: both configured calls fail as simulated failures
Func<Task> firstCall = () => client.GetAsync("https://localhost/flaky");
await firstCall.Should().ThrowAsync<HttpRequestException>();

Func<Task> secondCall = () => client.GetAsync("https://localhost/flaky");
await secondCall.Should().ThrowAsync<HttpRequestException>();

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<RequestInfo, HttpResponseMessage>(
_ => 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);
}
}

}
12 changes: 12 additions & 0 deletions Mockly/CapturedRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ public class CapturedRequest(RequestInfo request)
/// </summary>
public HttpResponseMessage Response { get; internal set; } = new();

/// <summary>
/// Gets the exception that was thrown to simulate a network-level failure for this request, or <c>null</c>
/// when the request produced a regular response.
/// </summary>
/// <remarks>
/// This is set when the matching mock was configured with one of the simulated-failure methods such as
/// <c>ThrowsException</c> or <c>TimesOut</c>. When set, the HTTP pipeline propagates this exception to the
/// <see cref="HttpClient"/> caller, and <see cref="Response"/> does not represent a response that was actually
/// returned to the caller.
/// </remarks>
public Exception? SimulatedFailure { get; internal set; }

/// <summary>
/// The mock that handled this request, if any.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions Mockly/HttpMock.cs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -269,6 +270,11 @@ private async Task<HttpResponseMessage> HandleRequest(HttpRequestMessage httpReq

Requests.Add(capturedRequest);

if (capturedRequest.SimulatedFailure is not null)
{
ExceptionDispatchInfo.Capture(capturedRequest.SimulatedFailure).Throw();
}

if (!foundMatch && FailOnUnexpectedCalls)
{
await ThrowDetailedException(request);
Expand Down
41 changes: 37 additions & 4 deletions Mockly/RequestMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ public class RequestMock
/// </summary>
internal TimeSpan? Delay { get; set; }

/// <summary>
/// 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 <c>500</c> response.
/// </summary>
internal SimulatedFailureResponder? SimulatedFailure { get; init; }

public RequestCollection? RequestCollection { get; init; } = [];

/// <summary>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -321,6 +336,16 @@ internal async Task<CapturedRequest> 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);
Expand Down Expand Up @@ -350,16 +375,24 @@ internal async Task<CapturedRequest> TrackRequestAsync(RequestInfo request, Canc
/// </summary>
private async Task<HttpResponseMessage> 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);
}

/// <summary>
/// Awaits the configured <see cref="Delay"/> (if any), honoring the supplied <paramref name="cancellationToken"/>.
/// </summary>
private async Task ApplyConfiguredDelayAsync(CancellationToken cancellationToken)
{
if (Delay is { } delay && delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken);
}
}

/// <summary>
/// Builds a detailed textual representation of this mock, including its route
/// and any configured custom matchers.
Expand Down
71 changes: 71 additions & 0 deletions Mockly/RequestMockBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -672,4 +672,75 @@
mockBuilder.AddMock(mock);
return new RequestMockResponseBuilder(mock);
}

/// <summary>
/// Configures the mock to simulate a network-level failure by throwing the specified <paramref name="exception"/>
/// instead of returning a response.
/// </summary>
/// <remarks>
/// The exception is propagated to the <see cref="System.Net.Http.HttpClient"/> caller rather than being converted
/// into a <c>500 Internal Server Error</c> response, allowing tests to verify retry, circuit-breaker and other
/// resilience behavior. The matching request is still recorded in <see cref="HttpMock.Requests"/> before the
/// exception is thrown. The same exception instance is thrown on every matching invocation.
/// </remarks>
/// <param name="exception">The exception to throw for a matching request.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="exception"/> is <c>null</c>.</exception>
public RequestMockResponseBuilder ThrowsException(Exception exception)

Check notice

Code scanning / InspectCode

Method return value is never used: Non-private accessibility Note

Method 'ThrowsException' return value is never used
{
if (exception is null)
{
throw new ArgumentNullException(nameof(exception));
}

return RespondsWithSimulatedFailure(() => exception);
}

/// <summary>
/// Configures the mock to simulate a network-level failure by throwing a freshly constructed
/// <typeparamref name="TException"/> instead of returning a response.
/// </summary>
/// <remarks>
/// A new instance of <typeparamref name="TException"/> is created for every matching invocation. The exception is
/// propagated to the <see cref="System.Net.Http.HttpClient"/> caller rather than being converted into a
/// <c>500 Internal Server Error</c> response. The matching request is still recorded in
/// <see cref="HttpMock.Requests"/> before the exception is thrown.
/// </remarks>
/// <typeparam name="TException">The type of exception to throw for a matching request.</typeparam>
public RequestMockResponseBuilder ThrowsException<TException>()
where TException : Exception, new()
{
return RespondsWithSimulatedFailure(static () => new TException());
}

/// <summary>
/// Configures the mock to simulate an <see cref="System.Net.Http.HttpClient"/> timeout by throwing a
/// <see cref="TaskCanceledException"/> instead of returning a response.
/// </summary>
/// <remarks>
/// This mimics the behavior of a real <see cref="System.Net.Http.HttpClient"/> whose request exceeds its
/// configured timeout. The exception is propagated to the caller and the matching request is still recorded in
/// <see cref="HttpMock.Requests"/> before the exception is thrown.
/// </remarks>
public RequestMockResponseBuilder TimesOut()

Check notice

Code scanning / InspectCode

Method return value is never used: Non-private accessibility Note

Method 'TimesOut' return value is never used
{
return RespondsWithSimulatedFailure(static () => new TaskCanceledException());
}

private RequestMockResponseBuilder RespondsWithSimulatedFailure(Func<Exception> 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);
}
}
Loading
Loading