This document describes each method available on FortitudeHandlerBuilder. Examples focus only on the method being described and assume chaining from fortitudeClient.Accepts().
Starts building a request handler for a FortitudeClient.
fortitudeClient.Accepts()Adds a custom predicate that must return true for the request to match.
.Matches(req => req.Route == "/health")Matches a specific HTTP method.
.Method("GET")Matches HTTP GET requests.
.Get()Matches HTTP POST requests.
.Post()Matches HTTP PUT requests.
.Put()Matches HTTP DELETE requests.
.Delete()Matches HTTP PATCH requests.
.Patch()Matches HTTP OPTIONS requests.
.Options()Matches multiple HTTP methods.
.Methods("GET", "POST")Clears method restrictions and matches any HTTP method.
.AnyMethod()Matches an exact HTTP route.
.HttpRoute("/users")Matches routes that start with the given prefix.
.RouteStartsWith("/api")Matches routes that end with the given suffix.
.RouteEndsWith("/status")Requires a specific header and value.
.Header("X-Env", "test")Requires a header to exist, regardless of value.
.HeaderExists("X-Request-Id")Matches an exact Authorization header value.
.Authorization("Basic abc123")Matches a Bearer token Authorization header.
.BearerToken("my-token")Matches the Accept header.
.Accept("application/json")Matches the Content-Type header.
.ContentType("application/json")Matches the User-Agent header.
.UserAgent("Fortitude-Test")Requires a query parameter with a specific value.
.QueryParam("page", "1")Requires a query parameter to exist, regardless of value.
.QueryParamExists("debug")Adds multiple required query parameters.
.QueryParams(new Dictionary<string, string>
{
["sort"] = "asc",
["limit"] = "10"
})Adds a custom predicate for matching the request body.
.Body(body => Encoding.UTF8.GetString(body!) == "hello")Matches requests with no body or an empty body.
.BodyIsEmpty()Matches requests whose UTF-8 body contains the given text.
.BodyContains("hello")Matches requests with Content-Type set to application/json.
.JsonBody()Deserializes the JSON body and applies a predicate to the result.
.JsonBody<MyDto>(dto => dto.IsActive)Registers a synchronous responder.
.Returns((req, res) =>
{
res.Ok();
})Registers an asynchronous responder.
.Returns(async (req, res) =>
{
await Task.Delay(10);
res.Ok();
})WithDelay holds the response back, so the system under test experiences a slow dependency.
This is how you exercise timeouts, retries and circuit breakers.
fortitude.Accepts()
.Get()
.HttpRoute("/users")
.WithDelay(TimeSpan.FromSeconds(2))
.Returns((req, res) => res.Ok(users));WithDelay also takes milliseconds directly, and WithRandomDelay draws a fresh delay from a
range on every request to simulate jitter:
.WithDelay(250) // always 250ms
.WithRandomDelay(100, 500) // uniform between 100ms and 500msTwo things worth knowing:
- The server gives up on a handler after
Settings:ResponseTimeoutSeconds(5 seconds by default) and returns504. A delay beyond that is cut short - which is exactly how you test a client's timeout handling, but raise the setting if it is not what you meant. WaitForRequestAsynccompletes as soon as the request arrives, before the delay elapses, so asserting that the dependency was called does not cost you the simulated latency.
In YAML the same options live on the response:
response:
status: 200
delayMs: 2000response:
status: 200
delay:
minMs: 100
maxMs: 500Times(n), and its shorthands Once() and Twice(), stop a handler matching once it has
answered that many requests. An exhausted handler steps aside and the next matching one takes
over, which is how retry behaviour is expressed:
// Registered first, so it is the fallback.
fortitude.Accepts().Get().HttpRoute("/flaky")
.Returns((req, res) => res.Ok("recovered"));
// Registered later, so it wins - until it has answered twice.
fortitude.Accepts().Get().HttpRoute("/flaky")
.Twice()
.Returns((req, res) => res.InternalServerError("down"));The first two calls get a 500, the third gets "recovered". CallCount and IsExhausted on the
returned handler let you assert on it afterwards.
With no fallback registered, requests after the limit fall through to 501 Not Implemented.
Under genuinely concurrent requests the limit is best effort - two requests can both pass the check before either is counted. Sequential use, the normal case in tests, is exact.
ReturnsInSequence walks a list of responders, repeating the last one once the sequence runs
out. Useful for polling flows:
fortitude.Accepts().Get().HttpRoute("/jobs/1")
.ReturnsInSequence(
(req, res) => res.Ok(new { status = "pending" }),
(req, res) => res.Ok(new { status = "pending" }),
(req, res) => res.Ok(new { status = "complete" }));When several handlers match the same request, the most recently registered one answers. That default is usually what you want - it lets a test override a fixture set up in a base class.
Priority(n) overrides it explicitly. Higher wins, the default is 0, and negatives are
useful for a catch-all that should only apply when nothing else matches:
fortitude.Accepts().Get().RouteStartsWith("/")
.Priority(-100)
.Returns((req, res) => res.NotFound("no handler for this route"));An error status is the easy case for a client to handle. ReturnsFault produces the harder
ones: a dependency that goes away mid-request, or answers with something unusable.
fortitude.Accepts().Get().HttpRoute("/users").ReturnsFault(HttpFault.ConnectionReset);| Fault | What the caller sees |
|---|---|
ConnectionReset |
The connection is aborted with no response at all - a socket error, not a status code. In intercept mode this surfaces as HttpRequestException. |
EmptyBody |
A success status with no content, where content was expected. |
MalformedJson |
application/json whose body is truncated mid-object and will not parse. |
RandomBytes |
application/json whose body is binary noise. |
To fail only some of the time, FailsRandomly wraps the normal responder:
fortitude.Accepts().Get().HttpRoute("/users")
.FailsRandomly(0.2) // 20% of calls get a 500
.Returns((req, res) => res.Ok(users));The failure response is configurable, which is handy for rate limiting:
.FailsRandomly(0.1, (req, res) => res.TooManyRequests("slow down", retryAfterSeconds: 30))Faults combine with the other modifiers, so a handler can fail the first call and then behave:
fortitude.Accepts().Get().HttpRoute("/users")
.Once()
.ReturnsFault(HttpFault.ConnectionReset);Sometimes the same route has to answer differently depending on what has already happened -
a job that reports queued, then running, then complete. A scenario is a named state
machine that lets handlers express that without knowing about each other.
fortitude.Accepts().Get().HttpRoute("/jobs/1")
.InScenario("job").WhenState("Started").ThenState("Running")
.Returns((req, res) => res.Ok(new { status = "queued" }));
fortitude.Accepts().Get().HttpRoute("/jobs/1")
.InScenario("job").WhenState("Running").ThenState("Done")
.Returns((req, res) => res.Ok(new { status = "running" }));
fortitude.Accepts().Get().HttpRoute("/jobs/1")
.InScenario("job").WhenState("Done")
.Returns((req, res) => res.Ok(new { status = "complete" }));Every scenario begins in Started (FortitudeScenarios.InitialState). A handler matches only
while the scenario is in its WhenState, and moves it to ThenState once it has answered.
Omit WhenState to match in any state; omit ThenState to leave the state alone. A scenario
with neither is refused, since it could only be a mistake.
fortitude.Scenarios exposes the state directly, which is useful for setting up midway through
a flow or resetting between test cases:
fortitude.Scenarios.GetState("job"); // "Running"
fortitude.Scenarios.SetState("job", "Done");
fortitude.Scenarios.Reset("job"); // back to "Started"
fortitude.Scenarios.ResetAll();Scenarios work the same way in YAML, where handlers across different files can share one flow:
handlers:
- match:
route: /job
scenario:
name: job
whenState: Started
thenState: Running
response:
status: 200
body:
text: queued