|
| 1 | +# Testing Refit clients with `Refit.Testing` |
| 2 | + |
| 3 | +`Refit.Testing` is a first-party package for testing the Refit clients your app depends on, without a mocking |
| 4 | +library and without a live server. You describe the calls you expect as a **route table** — each entry pairs a |
| 5 | +`Route` (which request to match) with a `Reply` (what to send back) — and point a real Refit client at it. |
| 6 | + |
| 7 | +Because the library is Refit-aware, it does three things a general-purpose HTTP mock cannot: |
| 8 | + |
| 9 | +- **Route templates** mirror the `[Get("/users/{id}")]` attributes on your interface, so you don't restate URLs. |
| 10 | +- **Typed replies** are serialized with the *client's own* serializer, so you never hand-write JSON. |
| 11 | +- **Typed request capture** deserializes the body your client sent back into an object you can assert on. |
| 12 | + |
| 13 | +## Contents |
| 14 | + |
| 15 | +- [Installation](#installation) |
| 16 | +- [Quick start](#quick-start) |
| 17 | +- [Creating a client](#creating-a-client) |
| 18 | +- [Routes: matching requests](#routes-matching-requests) |
| 19 | +- [Replies: sending responses](#replies-sending-responses) |
| 20 | +- [Verifying the calls that were made](#verifying-the-calls-that-were-made) |
| 21 | +- [Inspecting the request that was sent](#inspecting-the-request-that-was-sent) |
| 22 | +- [Sequenced and reusable routes](#sequenced-and-reusable-routes) |
| 23 | +- [Simulating network conditions](#simulating-network-conditions) |
| 24 | +- [Unit-testing code that consumes `IApiResponse<T>`](#unit-testing-code-that-consumes-iapiresponset) |
| 25 | +- [API reference](#api-reference) |
| 26 | + |
| 27 | +## Installation |
| 28 | + |
| 29 | +```sh |
| 30 | +dotnet add package Refit.Testing |
| 31 | +``` |
| 32 | + |
| 33 | +The package targets the same frameworks as Refit itself, so it works wherever your tests run. |
| 34 | + |
| 35 | +## Quick start |
| 36 | + |
| 37 | +Given a Refit interface: |
| 38 | + |
| 39 | +```csharp |
| 40 | +public interface IGitHubApi |
| 41 | +{ |
| 42 | + [Get("/users/{id}")] |
| 43 | + Task<User> GetUser(int id); |
| 44 | + |
| 45 | + [Post("/users")] |
| 46 | + Task<User> CreateUser([Body] NewUser user); |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +A test looks like this: |
| 51 | + |
| 52 | +```csharp |
| 53 | +using Refit.Testing; |
| 54 | + |
| 55 | +[Test] |
| 56 | +public async Task GetsAUser() |
| 57 | +{ |
| 58 | + var http = new StubHttp |
| 59 | + { |
| 60 | + { Route.Get("/users/{id}"), Reply.With(new User(7, "octocat")) }, |
| 61 | + }; |
| 62 | + |
| 63 | + var api = http.CreateClient<IGitHubApi>("https://api.github.com"); |
| 64 | + |
| 65 | + var user = await api.GetUser(7); |
| 66 | + |
| 67 | + Assert.Equal("octocat", user.Login); |
| 68 | + await http.VerifyAllCalledAsync(); |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +`Reply.With(...)` serializes the `User` with the same serializer the client uses, and `Route.Get("/users/{id}")` |
| 73 | +matches the URL the `[Get("/users/{id}")]` attribute produces — no JSON strings, no full URLs. |
| 74 | + |
| 75 | +## Creating a client |
| 76 | + |
| 77 | +`StubHttp` is an `HttpMessageHandler`. The most direct way to get a client wired to it is `CreateClient<T>`: |
| 78 | + |
| 79 | +```csharp |
| 80 | +var api = http.CreateClient<IGitHubApi>("https://api.github.com"); |
| 81 | +``` |
| 82 | + |
| 83 | +This is shorthand for `RestService.For<IGitHubApi>("https://api.github.com", http.ToSettings())`. To keep a custom |
| 84 | +serializer or other `RefitSettings`, pass them and they are routed through the handler: |
| 85 | + |
| 86 | +```csharp |
| 87 | +var settings = new RefitSettings(new SystemTextJsonContentSerializer(myOptions)); |
| 88 | +var api = http.CreateClient<IGitHubApi>("https://api.github.com", settings); |
| 89 | +``` |
| 90 | + |
| 91 | +For trim- or AOT-compiled test hosts, use `CreateGeneratedClient<T>`, which uses the source-generated client |
| 92 | +instead of the reflection path (a generated implementation must be registered for the interface): |
| 93 | + |
| 94 | +```csharp |
| 95 | +var api = http.CreateGeneratedClient<IGitHubApi>("https://api.github.com"); |
| 96 | +``` |
| 97 | + |
| 98 | +If you build the `HttpClient` yourself (for example to exercise `HttpClientFactory` wiring), `ToSettings()` and |
| 99 | +`ToSettings(existing)` return a `RefitSettings` whose handler factory points at the stub. |
| 100 | + |
| 101 | +## Routes: matching requests |
| 102 | + |
| 103 | +Build the common routes with the `Route` factory, one method per verb: |
| 104 | + |
| 105 | +```csharp |
| 106 | +Route.Get("/users/{id}") |
| 107 | +Route.Post("/users") |
| 108 | +Route.Delete("/users/{id}") |
| 109 | +Route.Any("/health") // matches any HTTP method |
| 110 | +Route.For(new HttpMethod("PATCH"), "/users/{id}") |
| 111 | +``` |
| 112 | + |
| 113 | +The template is matched a path segment at a time: |
| 114 | + |
| 115 | +- `{name}` matches any single non-empty segment (`/users/{id}` matches `/users/7`). |
| 116 | +- A **relative** template (`/users/1`) matches the request's absolute path. |
| 117 | +- An **absolute** template (`https://api.github.com/users/1`) matches the whole scheme/host/path. |
| 118 | +- `"*"` matches any path. |
| 119 | + |
| 120 | +The query string is matched separately (see below), so `Route.Get("/search")` matches `/search?q=refit`. |
| 121 | + |
| 122 | +For finer matching, construct a `RouteMatcher` directly and set only the properties you need: |
| 123 | + |
| 124 | +```csharp |
| 125 | +new RouteMatcher |
| 126 | +{ |
| 127 | + Method = HttpMethod.Get, |
| 128 | + Template = "/search", |
| 129 | + Query = [("q", "refit")], // partial: these pairs must be present, others allowed |
| 130 | + Headers = [("X-Trace", "abc")], // request or content headers |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +| Property | Matches when … | |
| 135 | +| ------------------ | ------------------------------------------------------------------------------ | |
| 136 | +| `Method` | the HTTP method equals this (`null` matches any method) | |
| 137 | +| `Template` | the path matches (see template rules above) — **required** | |
| 138 | +| `Query` | every listed query pair is present (extras allowed) | |
| 139 | +| `ExactQuery` | the raw query string equals this exactly | |
| 140 | +| `ExactQueryParams` | the decoded query pairs are exactly this set (no extras, order-insensitive) | |
| 141 | +| `Headers` | every listed header is present with the given value | |
| 142 | +| `Body` | the request body equals this string exactly | |
| 143 | +| `FormData` | every listed form field is present in the form-encoded body | |
| 144 | +| `Where` | the synchronous predicate returns `true` | |
| 145 | +| `WhereAsync` | the asynchronous predicate returns `true` (e.g. reading a streamed body) | |
| 146 | +| `Reusable` | see [Sequenced and reusable routes](#sequenced-and-reusable-routes) | |
| 147 | + |
| 148 | +An incoming request that matches no route **throws** rather than returning a canned 404, so a mistyped URL fails |
| 149 | +the test loudly instead of surfacing later as a confusing deserialization error. |
| 150 | + |
| 151 | +## Replies: sending responses |
| 152 | + |
| 153 | +Build responses with the `Reply` factory: |
| 154 | + |
| 155 | +```csharp |
| 156 | +Reply.With(new User(7, "octocat")) // typed body, serialized by the client's serializer, 200 |
| 157 | +Reply.With(new User(7, "octocat"), HttpStatusCode.Created) |
| 158 | +Reply.Json("{\"id\":7}") // raw JSON body, 200 |
| 159 | +Reply.Json("{\"error\":\"nope\"}", HttpStatusCode.BadRequest) |
| 160 | +Reply.Text("pong") // text/plain body |
| 161 | +Reply.Text("<b>hi</b>", "text/html") |
| 162 | +Reply.Status(HttpStatusCode.NoContent) // bare status, no body |
| 163 | +Reply.Content(new ByteArrayContent(bytes)) // explicit HttpContent |
| 164 | +Reply.From(request => new HttpResponseMessage(HttpStatusCode.OK) { Content = ... }) // full control |
| 165 | +Reply.From(async request => { ... return response; }) // async responder |
| 166 | +``` |
| 167 | + |
| 168 | +`Reply.With<T>` is the idiomatic choice: it serializes the object with the serializer configured on the client, so |
| 169 | +the response deserializes back into your model exactly as a real server's would. For the rare case that needs total |
| 170 | +control over the `HttpResponseMessage`, use `Reply.From`. As with routes, you can also build a `StubResponse` |
| 171 | +directly for uncommon combinations (e.g. a text body with a non-200 status). |
| 172 | + |
| 173 | +## Verifying the calls that were made |
| 174 | + |
| 175 | +Each non-reusable route is **one-shot**: it satisfies exactly one request and is then consumed. `VerifyAllCalled` |
| 176 | +asserts that every one was hit: |
| 177 | + |
| 178 | +```csharp |
| 179 | +http.VerifyAllCalled(); // throws if any expected route went unmatched |
| 180 | +``` |
| 181 | + |
| 182 | +When the request under test is fire-and-forget (an observable subscription, a background send), it may not have |
| 183 | +completed by the time you verify. `VerifyAllCalledAsync` waits for the outstanding requests to arrive before |
| 184 | +asserting, with a default one-second timeout you can override: |
| 185 | + |
| 186 | +```csharp |
| 187 | +await http.VerifyAllCalledAsync(); |
| 188 | +await http.VerifyAllCalledAsync(TimeSpan.FromSeconds(5)); |
| 189 | +``` |
| 190 | + |
| 191 | +## Inspecting the request that was sent |
| 192 | + |
| 193 | +Every request the handler receives is recorded in `Requests`: |
| 194 | + |
| 195 | +```csharp |
| 196 | +await api.GetUser(7); |
| 197 | + |
| 198 | +Assert.Equal("/users/7", http.Requests[0].RequestUri!.AbsolutePath); |
| 199 | +Assert.Equal(HttpMethod.Get, http.Requests[0].Method); |
| 200 | +``` |
| 201 | + |
| 202 | +To assert on the **body** your client sent, deserialize it as a typed object — the body is buffered at send time, |
| 203 | +so it is available even after the client has disposed the request: |
| 204 | + |
| 205 | +```csharp |
| 206 | +await api.CreateUser(new NewUser("mona")); |
| 207 | + |
| 208 | +var sent = await http.LastRequestBodyAsync<NewUser>(); |
| 209 | +Assert.Equal("mona", sent!.Login); |
| 210 | + |
| 211 | +// or by index, when several requests were made |
| 212 | +var first = await http.RequestBodyAsync<NewUser>(0); |
| 213 | +``` |
| 214 | + |
| 215 | +## Sequenced and reusable routes |
| 216 | + |
| 217 | +Multiple routes for the same endpoint are consumed **in declared order**, so successive calls get successive |
| 218 | +responses: |
| 219 | + |
| 220 | +```csharp |
| 221 | +var http = new StubHttp |
| 222 | +{ |
| 223 | + { Route.Get("/status"), Reply.Json("{\"state\":\"pending\"}") }, |
| 224 | + { Route.Get("/status"), Reply.Json("{\"state\":\"done\"}") }, |
| 225 | +}; |
| 226 | +// first GET /status -> pending, second GET /status -> done |
| 227 | +``` |
| 228 | + |
| 229 | +Set `Reusable = true` for a background stub that may match any number of requests and is **not** required by |
| 230 | +`VerifyAllCalled` — useful for an endpoint that is polled, or a catch-all: |
| 231 | + |
| 232 | +```csharp |
| 233 | +var http = new StubHttp |
| 234 | +{ |
| 235 | + { new RouteMatcher { Template = "*", Reusable = true }, Reply.Status(HttpStatusCode.OK) }, |
| 236 | +}; |
| 237 | +``` |
| 238 | + |
| 239 | +One-shot routes take priority over reusable ones, so you can special-case a single call while a reusable route |
| 240 | +handles the rest. |
| 241 | + |
| 242 | +## Simulating network conditions |
| 243 | + |
| 244 | +`NetworkBehavior` injects deterministic, seeded latency and failures across every matched request — the equivalent |
| 245 | +of Retrofit's `NetworkBehavior`. Pass it to the `StubHttp` constructor (or set the `Behavior` property): |
| 246 | + |
| 247 | +```csharp |
| 248 | +var behavior = new NetworkBehavior(seed: 1) |
| 249 | +{ |
| 250 | + Delay = TimeSpan.FromMilliseconds(200), // base latency per call |
| 251 | + Variance = 0.5, // +/- jitter as a fraction of Delay |
| 252 | + FailurePercent = 0.1, // chance of a thrown transport failure |
| 253 | + ErrorPercent = 0.2, // chance of an HTTP error response |
| 254 | + ErrorStatusCode = HttpStatusCode.InternalServerError, |
| 255 | +}; |
| 256 | + |
| 257 | +var http = new StubHttp(behavior) |
| 258 | +{ |
| 259 | + { Route.Get("/users/{id}"), Reply.With(new User(7, "octocat")) }, |
| 260 | +}; |
| 261 | +``` |
| 262 | + |
| 263 | +Seeding makes runs reproducible: the same seed produces the same sequence of delays and failures, so a flaky-path |
| 264 | +test is deterministic. Set `FailureFactory` to control the exception a simulated transport failure throws. |
| 265 | + |
| 266 | +## Unit-testing code that consumes `IApiResponse<T>` |
| 267 | + |
| 268 | +When the code under test is handed an `IApiResponse<T>` directly (rather than making an HTTP call), use |
| 269 | +`StubApiResponse<T>` — a hand-written `IApiResponse<T>` whose members are all `init`-only: |
| 270 | + |
| 271 | +```csharp |
| 272 | +IApiResponse<User> response = new StubApiResponse<User> |
| 273 | +{ |
| 274 | + IsSuccessStatusCode = true, |
| 275 | + StatusCode = HttpStatusCode.OK, |
| 276 | + Content = new User(7, "octocat"), |
| 277 | + HasContent = true, |
| 278 | +}; |
| 279 | + |
| 280 | +// flows exactly as a real response would: |
| 281 | +if (response.IsSuccessfulWithContent) |
| 282 | +{ |
| 283 | + Use(response.Content); // non-null here |
| 284 | +} |
| 285 | +``` |
| 286 | + |
| 287 | +Prefer `StubHttp` for end-to-end tests; reach for `StubApiResponse<T>` only when a method signature hands your code |
| 288 | +an `IApiResponse<T>` to react to. |
| 289 | + |
| 290 | +## API reference |
| 291 | + |
| 292 | +| Type | Purpose | |
| 293 | +| -------------------- | ----------------------------------------------------------------------------- | |
| 294 | +| `StubHttp` | The route-table `HttpMessageHandler`; also creates clients and captures bodies | |
| 295 | +| `Route` | Factory for the common `RouteMatcher` shapes (`Get`, `Post`, …) | |
| 296 | +| `RouteMatcher` | Declarative request matcher (method, template, query, headers, body, predicates) | |
| 297 | +| `Reply` | Factory for `StubResponse` values (`With<T>`, `Json`, `Status`, `From`, …) | |
| 298 | +| `StubResponse` | The response returned for a matched route | |
| 299 | +| `NetworkBehavior` | Seeded latency and fault injection | |
| 300 | +| `StubApiResponse<T>` | A hand-written `IApiResponse<T>` for unit tests that don't go through HTTP | |
| 301 | + |
| 302 | +Key `StubHttp` members: |
| 303 | + |
| 304 | +- `CreateClient<T>(hostUrl[, settings])` / `CreateGeneratedClient<T>(hostUrl[, settings])` — build a wired client |
| 305 | +- `ToSettings()` / `ToSettings(settings)` — a `RefitSettings` routed through the handler |
| 306 | +- `Requests` — every request received, in order |
| 307 | +- `LastRequestBodyAsync<T>()` / `RequestBodyAsync<T>(index)` — the sent body as a typed object |
| 308 | +- `VerifyAllCalled()` / `VerifyAllCalledAsync([timeout])` — assert every one-shot route was hit |
| 309 | +- `Behavior` — the `NetworkBehavior` applied to each matched request |
0 commit comments