Skip to content

Commit 339cb8d

Browse files
authored
feat: add Refit.Testing package for stubbing and verifying clients (#2184)
* feat: add Refit.Testing package for stubbing and verifying clients ## What kind of change does this PR introduce? - Feature: a new first-party `Refit.Testing` package, and the test suite migrated onto it (Moq and RichardSzalay.MockHttp removed). ## What is the new behavior? A declarative, Refit-aware way to test the clients your app builds: - Route table: describe expected calls as `Route` -> `Reply` entries in a collection initializer. - Route templates mirror the interface attributes (`Route.Get("/users/{id}")`), so URLs aren't restated. - Typed replies: `Reply.With<T>(obj)` serializes with the client's own serializer, no hand-written JSON. - Typed request capture: read the sent body back as an object with `LastRequestBodyAsync<T>()`. - One-line client creation: `CreateClient<T>` (reflection) and `CreateGeneratedClient<T>` (source-generated/AOT). - `NetworkBehavior` for seeded latency and fault injection. - `StubApiResponse<T>` for unit-testing code that consumes `IApiResponse<T>` directly. - Verification of the calls made via `VerifyAllCalled` / `VerifyAllCalledAsync`. Includes a usage guide under `docs/` and a README section. ## What is the current behavior? - Tests relied on Moq and RichardSzalay.MockHttp; there was no first-party testing story for consumers. ## What might this PR break? - None for consumers: the package is additive. * test: cover Refit.Testing route/reply/matcher and StubApiResponse surface - Add coverage for every Route/Reply factory, the exact-query/body/form matchers, template placeholder edge cases, typed request capture, and StubApiResponse error accessors. - Exercise the null-URI and unbufferable-body paths via HttpMessageInvoker and a throwing content. - Exclude the Consume race guard from coverage (only reachable under a non-deterministic request race). * test: add matcher branch coverage and drop incidental product reformatting - Cover the missing body/header/query matcher branches and the typed-capture media-type fallback so Refit.Testing is fully covered, branches included. - Revert 14 product files (and one example) that this PR had touched with whitespace only (format-run reindentation), removing pre-existing product code from the coverage patch. * test: cover the null-URI query-matching branch in StubHttp - Exercise a wildcard route with a query matcher against a request with no URI (via HttpMessageInvoker), covering the null branch of the query resolver that codecov flagged as the last partial.
1 parent 533bbbe commit 339cb8d

114 files changed

Lines changed: 7931 additions & 3084 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
[![Build](https://github.com/reactiveui/refit/actions/workflows/ci-build.yml/badge.svg)](https://github.com/reactiveui/refit/actions/workflows/ci-build.yml) [![codecov](https://codecov.io/github/reactiveui/refit/branch/main/graph/badge.svg?token=2guEgHsDU2)](https://codecov.io/github/reactiveui/refit)
66

7-
| | Refit | Refit.HttpClientFactory | Refit.Newtonsoft.Json |
8-
|---------|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
9-
| *NuGet* | [![NuGet](https://img.shields.io/nuget/v/Refit.svg)](https://www.nuget.org/packages/Refit/) | [![NuGet](https://img.shields.io/nuget/v/Refit.HttpClientFactory.svg)](https://www.nuget.org/packages/Refit.HttpClientFactory/) | [![NuGet](https://img.shields.io/nuget/v/Refit.Newtonsoft.Json.svg)](https://www.nuget.org/packages/Refit.Newtonsoft.Json/) |
7+
| | Refit | Refit.HttpClientFactory | Refit.Newtonsoft.Json | Refit.Testing |
8+
|---------|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
9+
| *NuGet* | [![NuGet](https://img.shields.io/nuget/v/Refit.svg)](https://www.nuget.org/packages/Refit/) | [![NuGet](https://img.shields.io/nuget/v/Refit.HttpClientFactory.svg)](https://www.nuget.org/packages/Refit.HttpClientFactory/) | [![NuGet](https://img.shields.io/nuget/v/Refit.Newtonsoft.Json.svg)](https://www.nuget.org/packages/Refit.Newtonsoft.Json/) | [![NuGet](https://img.shields.io/nuget/v/Refit.Testing.svg)](https://www.nuget.org/packages/Refit.Testing/) |
1010

1111
Refit is a library heavily inspired by Square's
1212
[Retrofit](http://square.github.io/retrofit) library, and it turns your REST
@@ -36,6 +36,10 @@ services
3636
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.github.com"));
3737
```
3838

39+
To test the clients you build with Refit, the [`Refit.Testing`](https://www.nuget.org/packages/Refit.Testing) package
40+
lets you stub responses and verify requests with a declarative route table — see
41+
[Testing your Refit clients](#testing-your-refit-clients).
42+
3943
# Table of Contents
4044

4145
* [Sponsors](#sponsors)
@@ -85,6 +89,7 @@ services
8589
* [Reading the request body that was sent](#reading-the-request-body-that-was-sent)
8690
* [Providing a custom ExceptionFactory](#providing-a-custom-exceptionfactory)
8791
* [ApiException deconstruction with Serilog](#apiexception-deconstruction-with-serilog)
92+
* [Testing your Refit clients](#testing-your-refit-clients)
8893

8994
### Sponsors
9095

@@ -132,6 +137,16 @@ The release also adds two opt-in, non-breaking knobs on `RefitSettings` for hard
132137
* `RefitSettings.MaxExceptionContentLength` — caps how many characters of an error response body are read into
133138
`ApiException.Content`, bounding memory use against hostile or oversized error responses. Defaults to unbounded.
134139

140+
#### New in V13.x
141+
142+
* **`Refit.Testing`** — a new first-party package for testing Refit clients without a mocking library or a live
143+
server. You describe expected calls as a route table (`Route``Reply`) and point a real client at it via
144+
`StubHttp.CreateClient<T>(...)`. Route templates mirror your interface attributes, typed replies (`Reply.With<T>`)
145+
are serialized with the client's own serializer, and the sent request body can be read back as a typed object with
146+
`LastRequestBodyAsync<T>()`. It also includes `NetworkBehavior` for seeded latency/fault injection and
147+
`StubApiResponse<T>` for unit-testing code that consumes `IApiResponse<T>`. See
148+
[Testing your Refit clients](#testing-your-refit-clients).
149+
135150
### V12.x.x
136151

137152
#### Breaking changes in V12.x
@@ -2214,3 +2229,35 @@ For users of [Serilog](https://serilog.net), you can enrich the logging of `ApiE
22142229
[Serilog.Exceptions.Refit](https://www.nuget.org/packages/Serilog.Exceptions.Refit) NuGet package. Details of how to
22152230
integrate this package into your applications can be
22162231
found [here](https://github.com/RehanSaeed/Serilog.Exceptions#serilogexceptionsrefit).
2232+
2233+
### Testing your Refit clients
2234+
2235+
The first-party [`Refit.Testing`](https://www.nuget.org/packages/Refit.Testing) package lets you test the Refit
2236+
clients your app depends on without a mocking library or a live server. You describe the calls you expect as a
2237+
**route table** — each entry pairs a `Route` (which request to match) with a `Reply` (what to send back) — and point
2238+
a real Refit client at it:
2239+
2240+
```csharp
2241+
using Refit.Testing;
2242+
2243+
var http = new StubHttp
2244+
{
2245+
{ Route.Get("/users/{id}"), Reply.With(new User(7, "octocat")) },
2246+
{ Route.Post("/users"), Reply.Status(HttpStatusCode.Created) },
2247+
};
2248+
2249+
var api = http.CreateClient<IGitHubApi>("https://api.github.com");
2250+
2251+
var user = await api.GetUser(7);
2252+
2253+
// assert the body your client sent, as a typed object:
2254+
var sent = await http.LastRequestBodyAsync<NewUser>();
2255+
await http.VerifyAllCalledAsync();
2256+
```
2257+
2258+
Because the handler is Refit-aware, route templates mirror your `[Get("/users/{id}")]` attributes, `Reply.With<T>`
2259+
serializes with the client's own serializer (no hand-written JSON), and you can read the sent request body back as a
2260+
typed object. It also ships `NetworkBehavior` for seeded latency/fault injection and `StubApiResponse<T>` for
2261+
unit-testing code that consumes `IApiResponse<T>` directly.
2262+
2263+
See the [testing guide](docs/testing.md) for the full walkthrough.

docs/testing.md

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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

Comments
 (0)