Class StubHandler
An HttpMessageHandler that answers requests with canned responses instead of calling DynamoDB, and records every request. Pass it as HttpHandler to test code that uses a DynamoDbClient.
public sealed class StubHandler : HttpMessageHandler, IDisposable
- Inheritance
-
StubHandler
- Implements
- Inherited Members
Examples
var handler = new StubHandler();
handler.Enqueue(StubResponse.Json("""{"Item":{"pk":{"S":"user-1"}}}"""));
handler.Enqueue(StubResponse.DynamoDbError("ResourceNotFoundException", "Requested resource not found."));
using var client = StubClient.Create(handler);
// ... call the code under test, then:
var operations = handler.Requests.Select(request => request.Operation);
Remarks
Testing code that uses a Table<T>. Give the code under test a client over this handler, queue what DynamoDB answers, and look at Requests:
StubClient.Create(stub, options => options with { TimeProvider = fake }). StubResponse writes the answers from entities and
ReadItem<T>() reads them back from the requests, so a test holds no DynamoDB JSON. There is no network and no container. Table<T> is
sealed and has no interface on purpose: the handler is the seam, and a test through it covers the mapping and the expressions too. Code that wants to fake its data
access altogether puts an interface of its own around it.
The handler picks the response for a request in this order: the next response queued with Enqueue(StubResponse); the responder function given to the constructor; DefaultResponse. If none applies, the request fails with an InvalidOperationException that says which request had no response.
A response of the wrong operation throws. A StubResponse from a factory is the answer to the operations it is written for, and a request of another operation that takes it
fails with an InvalidOperationException that names both operations and the position of the response in the queue, from wherever the response came. So a test that queued the
answers in the wrong order, or one too few, fails at the request instead of passing on a body the client read without complaint. Json(string, HttpStatusCode, IReadOnlyDictionary<string, string>?),
DynamoDbError(string, string, HttpStatusCode, string?) and new StubResponse() fit any operation. A response left in the queue is no error, because a test may queue more than one path needs:
ThrowIfResponsesLeft() at the end of a test checks that every one was taken.
The client signs and serializes requests as it would for DynamoDB, so the recorded requests show exactly what would go over the wire. The handler does not validate signatures or requests. Responses are returned as they are given, which also lets tests produce errors and broken responses.
The client retries throttling errors, server errors (HTTP 500, 502, 503, 504), network failures and timeouts, up to MaxAttempts attempts in all. Every attempt is a request to this handler: Requests lists them all, and each needs a response, so a test that simulates a failure must queue as many failures as it expects attempts (or set a DefaultResponse), followed by the response that ends the retries. StubClient sets MaxAttempts to 1, which shows exactly one request per call. The client waits a random time up to 20 seconds between attempts on its TimeProvider, so give the client a fake time provider to test retries without waiting.
Testing unprocessed items. A batch sends what DynamoDB left unprocessed again by itself, also with MaxAttempts at 1, which bounds
the attempts of a request and not the rounds of a batch. Each round is a request to this handler, so queue
Unprocessed(string, params ReadOnlySpan<WriteRequest>) for it, and what ends the batch after that. Before the next round the batch waits on the same
clock, for a random time below 20 seconds, so with a fake clock it stops there until the test moves the clock by that much, as the second example does. A wait can round
down to nothing, so a test does not count on one. FakeTimeProvider of Microsoft.Extensions.TimeProvider.Testing is such a clock. A test of what follows a batch that gives up
sets MaxStalledRounds(int) to 1 on the command: the first round that gets nothing done then ends the batch, with no wait and no fake clock.
A batch that DynamoDB takes in two rounds, tested without waiting for the second one, with clock a FakeTimeProvider the client was given:
handler.Enqueue(StubResponse.Unprocessed("Orders", StubResponse.Put(order)));
handler.Enqueue(StubResponse.Unprocessed("Orders"));
var import = service.ImportAsync(orders);
while (!import.IsCompleted)
{
clock.Advance(TimeSpan.FromSeconds(20));
await Task.Yield();
}
await import;
The handler is thread-safe. The client does not dispose a handler it was given, so a test can keep using it afterwards.
Constructors
StubHandler()
Initializes a new instance of the StubHandler class that answers with the responses queued by Enqueue(StubResponse).
public StubHandler()
StubHandler(Func<RecordedRequest, StubResponse>)
Initializes a new instance of the StubHandler class that asks a function for the response when no response is queued.
public StubHandler(Func<RecordedRequest, StubResponse> responder)
Parameters
responderFunc<RecordedRequest, StubResponse>Returns the response for a request. It may throw, for example an HttpRequestException to simulate a network error.
Exceptions
- ArgumentNullException
responderis null.
StubHandler(Func<RecordedRequest, CancellationToken, Task<StubResponse>>)
Initializes a new instance of the StubHandler class that asks an asynchronous function for the response when no response is queued.
public StubHandler(Func<RecordedRequest, CancellationToken, Task<StubResponse>> responder)
Parameters
responderFunc<RecordedRequest, CancellationToken, Task<StubResponse>>Returns the response for a request. It receives the cancellation token of the request, so a responder that waits can be used to test timeouts and cancellation.
Exceptions
- ArgumentNullException
responderis null.
Properties
DefaultResponse
Gets or sets the response for requests that no queued response and no responder answers. null (the default) makes such a request fail with an InvalidOperationException.
public StubResponse? DefaultResponse { get; set; }
Property Value
RecordRequests
Gets or sets a value indicating whether requests are recorded in Requests. Defaults to true. Turn it off for benchmarks and load tests: recording copies each request body and keeps it in memory.
public bool RecordRequests { get; set; }
Property Value
Requests
Gets the requests received so far, oldest first. The list is a snapshot; get it again after more requests.
public IReadOnlyList<RecordedRequest> Requests { get; }
Property Value
Methods
Enqueue(StubResponse)
Queues a response for a future request. Each queued response answers exactly one request, in the order queued, whatever its operation: a response of a StubResponse factory that is the answer to another operation makes the request throw.
public void Enqueue(StubResponse response)
Parameters
responseStubResponseThe response.
Exceptions
- ArgumentNullException
responseis null.
SendAsync(HttpRequestMessage, CancellationToken)
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
Parameters
requestHttpRequestMessagecancellationTokenCancellationToken
Returns
ThrowIfResponsesLeft()
Throws if responses queued with Enqueue(StubResponse) are still waiting for a request, which means that the code under test sent fewer requests than the test expected. Call it at the end of a test that wants to know; a leftover is no error otherwise, and Dispose() does not check.
public void ThrowIfResponsesLeft()
Exceptions
- InvalidOperationException
A queued response is left. The message gives the position of each in the order queued, and the operations it answers.