Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ namespace Mockly
}
public class RequestMockResponseBuilder
{
public Mockly.RequestMockResponseBuilder After(System.TimeSpan delay) { }
public Mockly.RequestMockResponseBuilder Once() { }
public Mockly.RequestMockResponseBuilder Times(uint count) { }
public Mockly.RequestMockResponseBuilder Twice() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ namespace Mockly
}
public class RequestMockResponseBuilder
{
public Mockly.RequestMockResponseBuilder After(System.TimeSpan delay) { }
public Mockly.RequestMockResponseBuilder Once() { }
public Mockly.RequestMockResponseBuilder Times(uint count) { }
public Mockly.RequestMockResponseBuilder Twice() { }
Expand Down
86 changes: 86 additions & 0 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,92 @@ public void Null_async_responder_with_cancellation_token_throws_argument_null_ex
}
}

public class WhenSimulatingResponseLatency
{
[Fact]
public async Task The_response_is_delayed_by_the_configured_amount()
{
// Arrange
var mock = new HttpMock();
var delay = TimeSpan.FromMilliseconds(200);

mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(delay);

var client = mock.GetClient();

// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.GetAsync("https://localhost/slow");
stopwatch.Stop();

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(150));
}

[Fact]
public async Task A_client_timeout_shorter_than_the_delay_throws_a_task_cancelled_exception()
{
// Arrange
var mock = new HttpMock();

mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(TimeSpan.FromSeconds(10));

var client = mock.GetClient();
client.Timeout = TimeSpan.FromMilliseconds(100);

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

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

[Fact]
public async Task An_externally_cancelled_token_cancels_the_wait()
{
// Arrange
var mock = new HttpMock();

mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(TimeSpan.FromSeconds(10));

var client = mock.GetClient();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var token = cts.Token;

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

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

[Fact]
public void A_negative_delay_is_rejected()
{
// Arrange
var mock = new HttpMock();

// Act
Action act = () => mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(TimeSpan.FromSeconds(-1));

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

public class WhenLimitingInvocations
{
[Fact]
Expand Down
16 changes: 14 additions & 2 deletions Mockly/RequestMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ public Func<RequestInfo, HttpResponseMessage> Responder
}
}

/// <summary>
/// Gets the artificial delay to apply before producing the response, simulating a slow endpoint.
/// When set, the asynchronous response path awaits this delay (honoring the supplied
/// <see cref="CancellationToken"/>) before invoking the responder.
/// </summary>
internal TimeSpan? Delay { get; set; }

/// <summary>
/// Gets the collection that receives every <see cref="CapturedRequest"/> handled by this mock.
/// When <c>null</c>, captured requests are not stored.
Expand Down Expand Up @@ -501,9 +508,14 @@ internal async Task<CapturedRequest> TrackRequestAsync(RequestInfo request, Canc
/// Invokes the responder for the given invocation index, awaiting asynchronous responders and
/// flowing the supplied <paramref name="cancellationToken"/> into them.
/// </summary>
private Task<HttpResponseMessage> InvokeResponderAsync(RequestInfo request, int invocationIndex, CancellationToken cancellationToken)
private async Task<HttpResponseMessage> InvokeResponderAsync(RequestInfo request, int invocationIndex, CancellationToken cancellationToken)
{
return GetResponderForInvocation(invocationIndex)(request, cancellationToken);
if (Delay is { } delay && delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken);
}

return await GetResponderForInvocation(invocationIndex)(request, cancellationToken);
}

/// <summary>
Expand Down
23 changes: 23 additions & 0 deletions Mockly/RequestMockResponseBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,28 @@ internal static void ApplyHeader(HttpResponseMessage response, string name, stri
response.Headers.TryAddWithoutValidation(name, values);
}
}

/// <summary>
/// Delays the response by the specified <paramref name="delay"/> before it is produced, simulating a slow endpoint.
/// </summary>
/// <remarks>
/// The delay is awaited on the asynchronous response path and honors the <see cref="CancellationToken"/> flowing
/// from the HTTP pipeline. If the request is cancelled (for example through <see cref="System.Net.Http.HttpClient.Timeout"/>
/// or an externally cancelled token) while the delay is in progress, a
/// <see cref="System.Threading.Tasks.TaskCanceledException"/> is thrown, just as a real <see cref="System.Net.Http.HttpClient"/> would.
/// </remarks>
/// <param name="delay">The amount of time to wait before producing the response.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="delay"/> is negative.</exception>
// ReSharper disable once UnusedMethodReturnValue.Global -- fluent builder method, consistent with Once()/Twice()/Times()
public RequestMockResponseBuilder After(TimeSpan delay)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
{
if (delay < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(delay), delay, "Cannot delay a response by a negative amount of time");
}

requestMock.Delay = delay;
return this;
}
}

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Unlike other HTTP mocking libraries, Mockly offers:
* **Zero configuration** - Works out of the box with sensible defaults
* **Performance optimized** - Regex patterns are cached for efficient matching
* **Invocation limits** - Restrict how many times a mock can respond using `Once()`, `Twice()`, or `Times(n)`
* **Response latency** - Simulate slow endpoints with `After(TimeSpan)` to test timeout, cancellation and resilience

### Who created this?

Expand Down
8 changes: 8 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ mock.AllMocksInvoked.Should().BeTrue(); // true when all mocks hit required co
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
Expand Down
19 changes: 19 additions & 0 deletions website/docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,25 @@ mock.ForDelete()
- `HttpMock.AllMocksInvoked` returns `true` only when each mock has been called at least once or has reached its configured `Times(..)` limit.
- `HttpMock.GetUninvokedMocks()` lists mocks that haven't reached their required count (or have 0 calls for unlimited mocks).

## Simulating Response Latency

Use `After(TimeSpan delay)` to delay a response, simulating a slow endpoint. This is useful for exercising timeout, cancellation, and resilience (e.g. Polly) behavior.

```csharp
var mock = new HttpMock();

mock.ForGet()
.WithPath("/slow")
.RespondsWithStatus(HttpStatusCode.OK)
.After(TimeSpan.FromSeconds(2));
```

### Behavior Notes

- The delay is awaited before the response is produced and honors the `CancellationToken` flowing from the HTTP pipeline.
- If `HttpClient.Timeout` is shorter than the delay, the request throws a `TaskCanceledException`, just like a real `HttpClient`.
- If the `CancellationToken` passed to the request is cancelled while the delay is in progress, an `OperationCanceledException` is thrown.

## Request Collection

Capture requests for specific mocks:
Expand Down
1 change: 1 addition & 0 deletions website/docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Unlike other HTTP mocking libraries, Mockly offers:
* **Performance optimized** - Regex patterns are cached for efficient matching
* **Invocation limits** - Restrict how many times a mock can respond using `Once()`, `Twice()`, or `Times(n)`
* **Sequenced responses** - Return different responses over consecutive calls by chaining `Then(...)`
* **Response latency** - Simulate slow endpoints with `After(TimeSpan)` to test timeout, cancellation and resilience

## Who created this?

Expand Down
Loading