From e5cb9bf2c71274b3b35838bf5858b49c24fe8745 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sat, 30 May 2026 09:53:29 +0200 Subject: [PATCH 1/2] Add simulated response latency .After(TimeSpan) (closes #117) Add an After(TimeSpan) method on RequestMockResponseBuilder that stores a delay on the RequestMock and awaits Task.Delay before invoking the responder on the async response path, honoring the CancellationToken flowing from SendAsync. A cancelled token (e.g. via HttpClient.Timeout or an external token) throws TaskCanceledException/OperationCanceledException as a real HttpClient would. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApprovedApi/net472.verified.txt | 1 + .../ApprovedApi/net8.0.verified.txt | 1 + Mockly.Specs/HttpMockSpecs.cs | 85 +++++++++++++++++++ Mockly/RequestMock.cs | 16 +++- Mockly/RequestMockResponseBuilder.cs | 22 +++++ README.md | 1 + SKILL.md | 8 ++ website/docs/advanced.md | 19 +++++ website/docs/intro.md | 1 + 9 files changed, 152 insertions(+), 2 deletions(-) diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt index d696480..0afc5bc 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt @@ -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() { } diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt index 371e8ec..cf3e742 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt @@ -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() { } diff --git a/Mockly.Specs/HttpMockSpecs.cs b/Mockly.Specs/HttpMockSpecs.cs index 962f053..4f61d90 100644 --- a/Mockly.Specs/HttpMockSpecs.cs +++ b/Mockly.Specs/HttpMockSpecs.cs @@ -2278,6 +2278,91 @@ 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 act = () => client.GetAsync("https://localhost/slow"); + + // Assert + await act.Should().ThrowAsync(); + } + + [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)); + + // Act + Func act = () => client.GetAsync("https://localhost/slow", cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task 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(); + } + } + public class WhenLimitingInvocations { [Fact] diff --git a/Mockly/RequestMock.cs b/Mockly/RequestMock.cs index 29cc035..9ae0b5a 100644 --- a/Mockly/RequestMock.cs +++ b/Mockly/RequestMock.cs @@ -98,6 +98,13 @@ public Func Responder } } + /// + /// 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 + /// ) before invoking the responder. + /// + internal TimeSpan? Delay { get; set; } + /// /// Gets the collection that receives every handled by this mock. /// When null, captured requests are not stored. @@ -501,9 +508,14 @@ internal async Task TrackRequestAsync(RequestInfo request, Canc /// Invokes the responder for the given invocation index, awaiting asynchronous responders and /// flowing the supplied into them. /// - private Task InvokeResponderAsync(RequestInfo request, int invocationIndex, CancellationToken cancellationToken) + private async Task 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); } /// diff --git a/Mockly/RequestMockResponseBuilder.cs b/Mockly/RequestMockResponseBuilder.cs index 1cb9f8d..8f8bb6d 100644 --- a/Mockly/RequestMockResponseBuilder.cs +++ b/Mockly/RequestMockResponseBuilder.cs @@ -135,5 +135,27 @@ internal static void ApplyHeader(HttpResponseMessage response, string name, stri response.Headers.TryAddWithoutValidation(name, values); } } + + /// + /// Delays the response by the specified before it is produced, simulating a slow endpoint. + /// + /// + /// The delay is awaited on the asynchronous response path and honors the flowing + /// from the HTTP pipeline. If the request is cancelled (for example through + /// or an externally cancelled token) while the delay is in progress, a + /// is thrown, just as a real would. + /// + /// The amount of time to wait before producing the response. + /// Thrown when is negative. + public RequestMockResponseBuilder After(TimeSpan delay) + { + 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; + } } diff --git a/README.md b/README.md index ee34dde..3de656e 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/SKILL.md b/SKILL.md index 88e8231..508d0c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -127,6 +127,14 @@ mock.AllMocksInvoked.Should().BeTrue(); // true when all mocks hit required co 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 diff --git a/website/docs/advanced.md b/website/docs/advanced.md index badf4c7..3d9c8a2 100644 --- a/website/docs/advanced.md +++ b/website/docs/advanced.md @@ -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: diff --git a/website/docs/intro.md b/website/docs/intro.md index 932efac..d810681 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -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? From 08c2b4d5b5b6adb9368c6e4696472b103e20b646 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Mon, 10 Aug 2026 13:24:22 +0200 Subject: [PATCH 2/2] Address code scanning review comments on PR #134 - Avoid capturing a disposed CancellationTokenSource in a closure by capturing the token value instead - Remove unused async modifier on a synchronous test - Suppress false-positive unused-return-value warning on the fluent After() builder method Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Mockly.Specs/HttpMockSpecs.cs | 5 +++-- Mockly/RequestMockResponseBuilder.cs | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Mockly.Specs/HttpMockSpecs.cs b/Mockly.Specs/HttpMockSpecs.cs index 4f61d90..97ca4ec 100644 --- a/Mockly.Specs/HttpMockSpecs.cs +++ b/Mockly.Specs/HttpMockSpecs.cs @@ -2338,16 +2338,17 @@ public async Task An_externally_cancelled_token_cancels_the_wait() var client = mock.GetClient(); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var token = cts.Token; // Act - Func act = () => client.GetAsync("https://localhost/slow", cts.Token); + Func act = () => client.GetAsync("https://localhost/slow", token); // Assert await act.Should().ThrowAsync(); } [Fact] - public async Task A_negative_delay_is_rejected() + public void A_negative_delay_is_rejected() { // Arrange var mock = new HttpMock(); diff --git a/Mockly/RequestMockResponseBuilder.cs b/Mockly/RequestMockResponseBuilder.cs index 8f8bb6d..0705748 100644 --- a/Mockly/RequestMockResponseBuilder.cs +++ b/Mockly/RequestMockResponseBuilder.cs @@ -147,6 +147,7 @@ internal static void ApplyHeader(HttpResponseMessage response, string name, stri /// /// The amount of time to wait before producing the response. /// Thrown when is negative. + // ReSharper disable once UnusedMethodReturnValue.Global -- fluent builder method, consistent with Once()/Twice()/Times() public RequestMockResponseBuilder After(TimeSpan delay) { if (delay < TimeSpan.Zero)