graph_weaver 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/docs/testing.md
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
How to run a spec that executes a GraphQL query without a server — against
|
|
4
4
|
fabricated data, against your own resolvers, across a federated graph, or
|
|
5
5
|
through your own transport with any of those behind it.
|
|
6
|
-
Setup is one require and one tag (`:wire` alone adds two test gems); the rest of
|
|
7
|
-
this page is what you reach for when an example is *about* the data, the
|
|
8
|
-
resolvers, or the transport.
|
|
9
6
|
|
|
10
7
|
One line in your spec helper:
|
|
11
8
|
|
|
@@ -14,8 +11,8 @@ One line in your spec helper:
|
|
|
14
11
|
require "graph_weaver/rspec"
|
|
15
12
|
```
|
|
16
13
|
|
|
17
|
-
Then **one tag says what an example runs against** — on the example, or on
|
|
18
|
-
|
|
14
|
+
Then **one tag says what an example runs against** — on the example, or on the
|
|
15
|
+
group it belongs to, since rspec metadata inherits:
|
|
19
16
|
|
|
20
17
|
```ruby
|
|
21
18
|
describe "checkout", graphql: :router do
|
|
@@ -36,63 +33,47 @@ it "sends the caller tag", graphql: :wire do … end
|
|
|
36
33
|
| `:live` | the app's own client is the point, or this one example wants out of `config.default_mode` | whatever your client does — this is the default |
|
|
37
34
|
| [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
graph
|
|
41
|
-
a
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
before it asks any client for anything, so a
|
|
46
|
-
[client-side `InputError`](errors.md#what-an-inputerror-says-without-reading-english)
|
|
47
|
-
raises the same way under every tag and under none. "Which mode do I need for
|
|
48
|
-
this" has no answer there — pick `:fake`, the cheapest.
|
|
49
|
-
|
|
50
|
-
**The other half is the one `:fake` can't reach.** It fabricates a
|
|
51
|
-
shape-correct *success*, so your server's `validates:` rules and custom
|
|
52
|
-
validators never run and nothing is ever rejected — a `:fake`-only suite has
|
|
53
|
-
zero coverage of server-side refusal. Cover it with
|
|
54
|
-
[`Failure.graphql(code:, extensions:)`](#simulating-failures) for the rejection
|
|
55
|
-
you expect, or with `:in_process`, where the real validators do run.
|
|
36
|
+
There is nothing else to set up: each mode works out what to run against per
|
|
37
|
+
graph, and [refuses rather than guessing](#nothing-to-configure). The tag
|
|
38
|
+
installs a stand-in per graph, and every generated module of that graph runs
|
|
39
|
+
against it — including one whose graph names a `client` of its own, since that
|
|
40
|
+
is exactly what the tag means to replace. `rspec --tag graphql:router`
|
|
41
|
+
runs one mode's examples.
|
|
56
42
|
|
|
57
43
|
**Every example has exactly one mode.** An untagged one takes
|
|
58
44
|
`config.default_mode`, which is `:live` — your own client, exactly as it is —
|
|
59
45
|
unless the suite sets another; and **`graphql: :live` is how one example steps
|
|
60
46
|
back out** of a default the suite did set.
|
|
61
47
|
|
|
62
|
-
`GraphWeaver.client` is **snapshotted before every example and restored
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
[Client resolution](transports.md#client-resolution) has the full order.
|
|
48
|
+
`GraphWeaver.client` is **snapshotted before every example and restored after**,
|
|
49
|
+
whatever the example did to it, so building your own client is a plain
|
|
50
|
+
assignment: `before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }`.
|
|
51
|
+
Do both at once and **the tag wins**: the assignment reads back while the modules
|
|
52
|
+
keep using the mode. Two things do step out of a tag — a per-call `client:`, and
|
|
53
|
+
a module [parsed](generated_modules.md#dynamic-mode) from a client of its own,
|
|
54
|
+
which runs against that client
|
|
55
|
+
([client resolution](transports.md#client-resolution) has the full order).
|
|
56
|
+
|
|
57
|
+
**The half `:fake` can't reach** is refusal. It fabricates a shape-correct
|
|
58
|
+
*success*, so your server's `validates:` rules and custom validators never run —
|
|
59
|
+
a `:fake`-only suite has zero coverage of server-side rejection. Cover it with
|
|
60
|
+
[`Failure.graphql(code:, extensions:)`](#simulating-failures) for the rejection
|
|
61
|
+
you expect, or with `:in_process`, where the real validators do run. (A bad
|
|
62
|
+
*variable* never gets as far as a mode: `execute` coerces before it asks any
|
|
63
|
+
client for anything, so a
|
|
64
|
+
[client-side `InputError`](errors.md#what-an-inputerror-says-without-reading-english)
|
|
65
|
+
raises the same way under every tag and under none.)
|
|
83
66
|
|
|
84
67
|
Everything here is a *client* — the one interface queries run through (the
|
|
85
|
-
contract is in [transports](transports.md))
|
|
86
|
-
cassettes
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
and the module runs on what parsed it:
|
|
68
|
+
contract is in [transports](transports.md)) — so fakes, the router, failures and
|
|
69
|
+
cassettes work outside rspec too (`require "graph_weaver/testing"`, never from
|
|
70
|
+
production code). There's no `GraphWeaver.client` to lean on there, so parse from
|
|
71
|
+
the fake or the router itself: anything holding a schema parses, and the module
|
|
72
|
+
runs on what parsed it.
|
|
91
73
|
|
|
92
74
|
```ruby
|
|
93
75
|
router = GraphWeaver::Testing::Router.new(supergraph: "app/graphql/supergraph.graphql")
|
|
94
76
|
DashboardQuery = router.parse("query Dashboard { me { username } }")
|
|
95
|
-
DashboardQuery.execute!.me.username
|
|
96
77
|
```
|
|
97
78
|
|
|
98
79
|
Every mode, tagged and running end to end, is
|
|
@@ -100,166 +81,33 @@ Every mode, tagged and running end to end, is
|
|
|
100
81
|
[`spec/wire_mode_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/wire_mode_spec.rb) for `:wire`) — the
|
|
101
82
|
reference for anything this page leaves out.
|
|
102
83
|
|
|
103
|
-
## Nothing to configure
|
|
104
|
-
|
|
105
|
-
Each mode works out what to run against **per graph** — with more than one, the
|
|
106
|
-
honest answer varies per module — and **refuses, naming what it looked for,
|
|
107
|
-
rather than guessing**:
|
|
108
|
-
|
|
109
|
-
- **the schema** is `config.schema` if you set one, else the one that
|
|
110
|
-
[graph](getting_started.md#more-than-one-schema) names, else the committed
|
|
111
|
-
dump at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks
|
|
112
|
-
to. A fake reads the scalar registrations of the graph it is answering, so it
|
|
113
|
-
invents the wire value that graph's generated cast expects. (Pins and
|
|
114
|
-
`overrides:` stay suite-wide, keyed by scalar name — one `"Money"` override
|
|
115
|
-
for the run.)
|
|
116
|
-
- **`:in_process`** needs the live schema *class*, since only that has
|
|
117
|
-
resolvers: the one that graph names, else the one your client already runs
|
|
118
|
-
in-process, else the loaded class that defines everything the schema
|
|
119
|
-
declares — the same derive-verify-refuse rule that
|
|
120
|
-
[maps subgraphs](federation.md#which-schema-serves-which-subgraph).
|
|
121
|
-
- **`:router`** plans against the composed supergraph **that graph** names,
|
|
122
|
-
else `config.router = { supergraph: … }`, else the committed dump when
|
|
123
|
-
*that* carries `@join__*` markers — which for a federated app is usually no
|
|
124
|
-
config at all. A graph that is in no supergraph is refused **by name**,
|
|
125
|
-
rather than planned against another graph's. A client can't stand in for
|
|
126
|
-
one: a client's schema is the API schema the router serves, with the
|
|
127
|
-
`@join__*` routing table stripped out. Subgraphs are derived either way.
|
|
128
|
-
|
|
129
|
-
**The helpers say which graph they mean.** `graphql_fake`,
|
|
130
|
-
`graphql_in_process` and `graphql_router` are the stand-in for the modules of
|
|
131
|
-
the graph their schema names — for your only graph when they name none — and
|
|
132
|
-
they refuse, naming your graphs, when there is none they could reach:
|
|
133
|
-
|
|
134
|
-
```ruby
|
|
135
|
-
graphql_fake("Product.name" => "Ada's Book", schema: Catalog::Schema)
|
|
136
|
-
graphql_in_process(Accounts::Schema)
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
So configure only to override a derivation, or to tune fabricated values — in
|
|
140
|
-
the same file as the require, since support files load in sorted order and one
|
|
141
|
-
naming `GraphWeaver::Testing` before it dies on `NameError`:
|
|
142
|
-
|
|
143
|
-
```ruby
|
|
144
|
-
GraphWeaver::Testing.configure do |config|
|
|
145
|
-
# config.schema = MySchema # the live class, rather than the dump
|
|
146
|
-
# config.router = { supergraph: Rails.root.join("supergraph.graphql") }
|
|
147
|
-
# config.router = { subgraphs: { "reviews" => :fake } } # either key alone
|
|
148
|
-
# config.context = { tenant: } # baseline context every example starts from
|
|
149
|
-
# config.default_mode = :fake # what an UNtagged example runs against;
|
|
150
|
-
# # :live (the default) leaves your client
|
|
151
|
-
# # alone, and graphql: :live opts one out
|
|
152
|
-
# config.seed = 4242 # defaults to rspec's own --seed
|
|
153
|
-
# config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
|
|
154
|
-
# config.list_size = 1..3
|
|
155
|
-
end
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
`list_size` is how long an **unbounded** list is — an Integer exactly that
|
|
159
|
-
many, a Range randomized within it, or a Hash saying it per list (below). A
|
|
160
|
-
list with a `first:`/`last:`/`limit:` argument is that long instead, whatever
|
|
161
|
-
this says.
|
|
162
|
-
|
|
163
|
-
**Every list the fabricator reaches reads the same setting, so nested lists
|
|
164
|
-
multiply.** A query selecting `rows { owner { … } tags }` with `tags`
|
|
165
|
-
uncapped fabricates `list_size` rows and `list_size` tags *in each of them* —
|
|
166
|
-
at 1600 that is 2.5M tags, and per-row allocations double with every doubling
|
|
167
|
-
of the number. Three nested lists cube it. Say it per list instead, keyed the
|
|
168
|
-
way a pin is (a `"Type.field"` coordinate or a bare field name), with
|
|
169
|
-
`default:` for the rest:
|
|
170
|
-
|
|
171
|
-
```ruby
|
|
172
|
-
config.list_size = { "Row.tags" => 3, default: 1000 }
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
which holds the inner list at 3 however large the outer one grows — or cap it
|
|
176
|
-
in the query (`tags(first: 3)`), where the query is yours to change.
|
|
177
|
-
|
|
178
|
-
**Configure at load, or in an `around` — never in a plain `before`.** The tag
|
|
179
|
-
builds this example's clients in a `before` hook of its own, and rspec runs
|
|
180
|
-
that one ahead of yours, so a `before` setting `config.schema`, `config.router`
|
|
181
|
-
or `config.context` arrives after the decision it meant to change. It is
|
|
182
|
-
**refused**, not ignored — a green example running against the wrong stand-in
|
|
183
|
-
is the expensive outcome. `Testing.configure` in the spec helper is the usual
|
|
184
|
-
place; an `around` wraps the tag's setup when one group needs its own:
|
|
185
|
-
|
|
186
|
-
```ruby
|
|
187
|
-
around do |example|
|
|
188
|
-
GraphWeaver::Testing.configure { |config| config.schema = Catalog::Schema }
|
|
189
|
-
example.run
|
|
190
|
-
end
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
For a single example the helper says it where it varies instead —
|
|
194
|
-
`graphql_in_process(MySchema)`, `graphql_fake(schema: MySchema)`,
|
|
195
|
-
`graphql_router(fake: …)`, `graphql_context(current_user: …)`.
|
|
196
|
-
|
|
197
|
-
**With more than one graph, `graph:` says which one a helper stands in for** —
|
|
198
|
-
`graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`,
|
|
199
|
-
`graphql_in_process(graph: :catalog)`, `graphql_router(graph: :storefront,
|
|
200
|
-
fake: …)`. A helper is the stand-in for one graph's modules, so an app with
|
|
201
|
-
several is refused, naming them, rather than guessing. A schema class names
|
|
202
|
-
its graph and its schema in one word — `graphql_in_process(Reviews::Schema)`
|
|
203
|
-
— but only for a graph that runs that class in-process; a graph whose schema
|
|
204
|
-
is a dump has no such object, and `graph:` is the handle every graph has.
|
|
205
|
-
|
|
206
|
-
**The rule: a helper sets the stand-in for the graph it names; the tag sets
|
|
207
|
-
the mode for every graph no helper named.** So one example can run two graphs
|
|
208
|
-
in two modes — the federated one through its router, the plain one faked —
|
|
209
|
-
and neither helper disturbs the other's graph:
|
|
210
|
-
|
|
211
|
-
```ruby
|
|
212
|
-
it "renders the dashboard", graphql: :router do
|
|
213
|
-
graphql_fake(graph: :countries, "Country.name" => "Canada")
|
|
214
|
-
# :storefront routes through its supergraph (the tag); :countries is faked
|
|
215
|
-
end
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
A helper naming one graph of several isn't contradicting the tag, so it isn't
|
|
219
|
-
refused. A helper that speaks for the whole example still is: with one graph,
|
|
220
|
-
or with no `graph:`/schema to narrow it, `graphql: :fake` plus
|
|
221
|
-
`graphql_in_process` is two answers to one question, and the later one winning
|
|
222
|
-
silently would hide which was the mistake.
|
|
223
|
-
|
|
224
|
-
Anything whose honest answer differs per example belongs on the fake instead
|
|
225
|
-
— `graphql_fake(null_chance: 1.0)` for the example that's about an empty
|
|
226
|
-
state, `graphql_fake(values: :literal)` for the one that reads better without
|
|
227
|
-
faker's prose. A suite-wide `null_chance` would sprinkle nils through every
|
|
228
|
-
*other* example, one run in ten, on a seed the failure doesn't name.
|
|
229
|
-
|
|
230
|
-
Need the schema itself inside an example — to sample a field, or build a
|
|
231
|
-
query on the fly? The client in play exposes it as
|
|
232
|
-
`GraphWeaver.client.schema`, and `GraphWeaver::Testing.config.schema` reads
|
|
233
|
-
back what `config.schema =` set, falling back to the committed dump.
|
|
234
|
-
|
|
235
84
|
## Fabricated data — `graphql: :fake`
|
|
236
85
|
|
|
237
|
-
`FakeClient` fabricates schema-correct responses for whatever query
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
values along with test order.
|
|
86
|
+
`FakeClient` fabricates schema-correct responses for whatever query arrives:
|
|
87
|
+
real enum values, valid `__typename` members, and — for a custom scalar — a
|
|
88
|
+
value the Ruby type you registered it as can hold, so every fake casts cleanly
|
|
89
|
+
through your generated structs. `@skip`/`@include` are evaluated against the
|
|
90
|
+
variables you passed (defaults included), so a field the server would leave out
|
|
91
|
+
is left out. `rspec --seed 1234` reproduces the values along with test order.
|
|
244
92
|
|
|
245
93
|
```ruby
|
|
246
|
-
|
|
94
|
+
it "shows the profile", graphql: :fake do
|
|
95
|
+
person = PersonQuery.execute!(id: "1").person
|
|
247
96
|
|
|
248
|
-
person
|
|
249
|
-
person.
|
|
250
|
-
|
|
97
|
+
person.name # a plausible name, when faker is loaded
|
|
98
|
+
person.birthday # a real Date
|
|
99
|
+
end
|
|
251
100
|
```
|
|
252
101
|
|
|
253
|
-
Values are semantic when the [faker](https://github.com/faker-ruby/faker) gem
|
|
254
|
-
|
|
255
|
-
(`"name-1"`, seeded numbers) when it isn't. Say `values: :literal` on a fake
|
|
256
|
-
|
|
257
|
-
|
|
102
|
+
Values are semantic when the [faker](https://github.com/faker-ruby/faker) gem is
|
|
103
|
+
loaded — `name` gets a name, `email` an email — and plain and type-derived
|
|
104
|
+
(`"name-1"`, seeded numbers) when it isn't. Say `values: :literal` on a fake that
|
|
105
|
+
reads better without the prose, or `values: :faker` to insist on the semantic
|
|
106
|
+
ones and get told if the gem is missing.
|
|
258
107
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
```
|
|
108
|
+
Outside a tagged example, build one yourself:
|
|
109
|
+
`GraphWeaver::Testing::FakeClient.new` (its `schema:` falls back to
|
|
110
|
+
`Testing.config`), then pass it as `client:`.
|
|
263
111
|
|
|
264
112
|
### Pins
|
|
265
113
|
|
|
@@ -275,90 +123,64 @@ graphql_fake("Money" => "12.00", # every Money field, however deep
|
|
|
275
123
|
|
|
276
124
|
Keys are schema vocabulary, so they survive query refactors — a type name, or
|
|
277
125
|
`"Type.field"` (a bare `"field"` pins it on every type) — and they are checked
|
|
278
|
-
and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
126
|
+
and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing and
|
|
127
|
+
leaving the example green against random data. **Schema vocabulary, not Ruby:** a
|
|
128
|
+
`countries` field generates a `Countries` struct, but the pin is `"Country"`,
|
|
129
|
+
spelled the way *that* schema spells it, so a Hasura table type is
|
|
130
|
+
`"pokemon_v2_pokemon"` and not a Ruby-cased guess at it.
|
|
131
|
+
|
|
132
|
+
A pin **merges**, and pins a **subtree** as readily as a leaf: name the fields
|
|
133
|
+
the example is about and everything else in the selection is still fabricated. A
|
|
134
|
+
pinned list is exactly as long as you write it — `{}` means "another one, all
|
|
135
|
+
fabricated".
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
graphql_fake("Reader.name" => "Ada",
|
|
139
|
+
"Reader.orders" => [{ "status" => "PAID" }, {}])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Inside a subtree the keys are *response* keys, as they come back on the wire
|
|
143
|
+
(`priceCents`, or an alias you selected); one the query doesn't select is refused
|
|
144
|
+
and spellchecked, same as a typo'd coordinate. At a union or interface, name the
|
|
145
|
+
member with `"__typename"`.
|
|
284
146
|
|
|
285
147
|
An **object pin** is anything answering the field names — a FactoryBot build, a
|
|
286
148
|
model, a `Struct`, an `OpenStruct`. For each selected field the fake calls the
|
|
287
|
-
snake_cased reader, puts
|
|
149
|
+
snake_cased reader, puts the value on the wire the way its
|
|
288
150
|
[scalar registration](scalars.md) serializes it (a `Time` as its iso8601 string,
|
|
289
151
|
a `T::Enum` as its value), recurses into nested objects and arrays of them, and
|
|
290
152
|
**fabricates any field the object doesn't answer**. Readers are field names, not
|
|
291
153
|
aliases; `__typename` comes from the key, so at a union or interface pin the
|
|
292
|
-
concrete type (`"Person"`, never `"Named"`).
|
|
293
|
-
|
|
294
|
-
isn't reproduced by `--seed`.
|
|
154
|
+
concrete type (`"Person"`, never `"Named"`). A FactoryBot sequence advances on
|
|
155
|
+
its own counter, which is the one thing `--seed` can't reproduce.
|
|
295
156
|
|
|
296
157
|
A **scalar type pin** is the one thing a scalar registered as *your own class*
|
|
297
158
|
needs — only `Money.parse` knows what wire value it accepts — so without one
|
|
298
|
-
fabrication refuses at the path it reached (`at reader.orders.0.total`) and
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
`
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
`
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
is
|
|
309
|
-
builds source rather than converting a value, so a registration spelled that
|
|
310
|
-
way has nothing to run: pin the wire value there, and the fake says so if you
|
|
311
|
-
don't.
|
|
159
|
+
fabrication refuses at the path it reached (`at reader.orders.0.total`) and names
|
|
160
|
+
the pin to add, rather than feeding your cast a placeholder that fails deep
|
|
161
|
+
inside `from_h`. A scalar registered as `BigDecimal`, `Time`, `Date`, `Integer`,
|
|
162
|
+
`Float`, `String` or `T::Boolean` needs nothing. The key is the **schema's scalar
|
|
163
|
+
name**, not the Ruby class it maps to, so two scalars that both deserialize into
|
|
164
|
+
`Money` want a pin each. What you pin there is **what the wire carries** —
|
|
165
|
+
`"12.00"`, not `Money.parse("12.00")` — though the object is accepted wherever
|
|
166
|
+
the registration can serialize one. (A `serialize:` **Proc** builds source rather
|
|
167
|
+
than converting a value, so a registration spelled that way has nothing to run:
|
|
168
|
+
pin the wire value, and the fake says so if you don't.) Suite-wide, the same hash
|
|
169
|
+
is `config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
|
|
312
170
|
|
|
313
171
|
Pins lead and options follow — `graphql_fake("Money" => "12.00", values:
|
|
314
|
-
:literal)`.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
option name. The refusal is the same at every door: `FakeClient.new`,
|
|
322
|
-
`graphql_fake`, `Router.new(fake:)` and `graphql_router(fake:)`.
|
|
172
|
+
:literal)`. They are the same keywords, told apart by a lookup: a key the fake
|
|
173
|
+
takes is an option, a key **your schema** knows is a pin, and a key that is
|
|
174
|
+
neither is refused naming both. `overrides:` takes the same hash by keyword, and
|
|
175
|
+
the leading pins win where both name a key — write it as that leading hash when a
|
|
176
|
+
schema's own vocabulary collides with an option name. The refusal is the same at
|
|
177
|
+
every door: `FakeClient.new`, `graphql_fake`, `Router.new(fake:)` and
|
|
178
|
+
`graphql_router(fake:)`.
|
|
323
179
|
|
|
324
180
|
**A pin answers every call the same way**, which is how a paging loop fed by a
|
|
325
181
|
fake runs forever — page two is as full as page one.
|
|
326
|
-
`GraphWeaver::Testing::Sequence` chains clients and repeats the
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
```ruby
|
|
330
|
-
page = GraphWeaver::Testing::FakeClient.new(schema:, overrides: { "pokemon_v2_pokemon" => [{ "name" => "pikachu" }] })
|
|
331
|
-
empty = GraphWeaver::Testing::FakeClient.new(schema:, overrides: { "pokemon_v2_pokemon" => [] })
|
|
332
|
-
|
|
333
|
-
GraphWeaver.client = GraphWeaver::Testing::Sequence.new(page, empty)
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
It is the same chain a retry test uses — [simulating
|
|
337
|
-
failures](#simulating-failures) has that one.
|
|
338
|
-
|
|
339
|
-
### The example that's *about* the data
|
|
340
|
-
|
|
341
|
-
Fabricated data answers "does this render", not "does it render Ada's two
|
|
342
|
-
orders". `graphql_fake` is the tag with options — same client, built where
|
|
343
|
-
the example can say what it needs:
|
|
344
|
-
|
|
345
|
-
```ruby
|
|
346
|
-
it "shows the two paid orders", graphql: :fake do
|
|
347
|
-
graphql_fake("Reader.name" => "Ada",
|
|
348
|
-
"Reader.orders" => [{ "status" => "PAID" }, {}])
|
|
349
|
-
|
|
350
|
-
expect(DashboardQuery.execute!.reader.orders.size).to eq 2
|
|
351
|
-
end
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
An override pins a **subtree** as readily as a leaf, and **merges**: name
|
|
355
|
-
the fields the example is about and everything else in the selection is
|
|
356
|
-
still fabricated. A pinned list is exactly as long as you write it — `{}`
|
|
357
|
-
means "another one, all fabricated". Inside a subtree the keys are
|
|
358
|
-
*response* keys, as they come back on the wire (`priceCents`, or an alias
|
|
359
|
-
you selected); one the query doesn't select is refused and spellchecked,
|
|
360
|
-
same as a typo'd coordinate. At a union or interface, name the member with
|
|
361
|
-
`"__typename"`.
|
|
182
|
+
`GraphWeaver::Testing::Sequence.new(page, empty)` chains clients and repeats the
|
|
183
|
+
last, so a fake whose pin is `[]` on the second one is what ends the loop.
|
|
362
184
|
|
|
363
185
|
`graphql_fake` returns the client, which records what it was asked:
|
|
364
186
|
|
|
@@ -369,17 +191,16 @@ expect(fake.requests.size).to eq 1 # memoized
|
|
|
369
191
|
expect(fake.requests.first[:variables]).to eq({ "id" => "1" })
|
|
370
192
|
```
|
|
371
193
|
|
|
372
|
-
It works in a `before` block, an example body, or a shared context — and
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
assert a memoization with `requests.size`, not by comparing two responses.
|
|
194
|
+
It works in a `before` block, an example body, or a shared context — and with no
|
|
195
|
+
tag at all, since it installs the client itself. The tag is `graphql_fake` with
|
|
196
|
+
no options. One thing to know: **two identical queries fabricate different
|
|
197
|
+
data**, so assert a memoization with `requests.size`, not by comparing two
|
|
198
|
+
responses.
|
|
378
199
|
|
|
379
200
|
## Real resolvers — `graphql: :in_process`
|
|
380
201
|
|
|
381
|
-
Your actual resolvers, your actual `context`, in the same process — no
|
|
382
|
-
|
|
202
|
+
Your actual resolvers, your actual `context`, in the same process — no socket,
|
|
203
|
+
no serialization, and a resolver's real backtrace when it raises.
|
|
383
204
|
|
|
384
205
|
```ruby
|
|
385
206
|
it "hides other people's drafts", graphql: :in_process do
|
|
@@ -388,73 +209,51 @@ it "hides other people's drafts", graphql: :in_process do
|
|
|
388
209
|
end
|
|
389
210
|
```
|
|
390
211
|
|
|
391
|
-
The live schema *class* is found for you (a schema dump has no resolvers,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
212
|
+
The live schema *class* is found for you (a schema dump has no resolvers, so it
|
|
213
|
+
won't do). If two loaded classes match, or none does, it says so and asks for
|
|
214
|
+
`config.schema = MySchema` — and in Rails, remember that an autoloaded schema
|
|
215
|
+
isn't loaded until something references it.
|
|
395
216
|
|
|
396
217
|
A federated app has no one live class, so the example says which subgraph it
|
|
397
|
-
means
|
|
398
|
-
`graphql: :router`, which plans across the
|
|
399
|
-
worth asking, and a suite asks them of
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
it "rejects a review from a blocked reader" do
|
|
403
|
-
graphql_in_process(Reviews::Schema)
|
|
404
|
-
…
|
|
405
|
-
end
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
Like `graphql_fake`, `graphql_in_process` needs no tag, works in a `before`
|
|
409
|
-
block, and is restored after the example.
|
|
218
|
+
means: `graphql_in_process(Reviews::Schema)`. Testing one subgraph's resolvers
|
|
219
|
+
directly is a different question from `graphql: :router`, which plans across the
|
|
220
|
+
whole graph and stitches; both are worth asking, and a suite asks them of
|
|
221
|
+
different subgraphs. Like `graphql_fake`, `graphql_in_process` needs no tag,
|
|
222
|
+
works in a `before` block, and is restored after the example.
|
|
410
223
|
|
|
411
224
|
### The context your resolvers see
|
|
412
225
|
|
|
413
|
-
`graphql_context` is available in every example
|
|
414
|
-
`config.context`
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
226
|
+
`graphql_context` is available in every example, and is the *only* way to set a
|
|
227
|
+
context from inside one — `config.context` is the suite baseline, read when an
|
|
228
|
+
example's clients are built, before any `before` hook runs, so setting it there
|
|
229
|
+
is refused rather than silently dropped. It **merges** onto that baseline,
|
|
230
|
+
reaches every stand-in the example runs through (all your graphs', and the ones
|
|
231
|
+
`:wire` serves behind its endpoints), and is **reset before the next example**,
|
|
232
|
+
so one example running as somebody else can't leak into the one after it.
|
|
419
233
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
built — before any `before` hook runs — so setting it there is refused rather
|
|
423
|
-
than silently dropped.
|
|
424
|
-
|
|
425
|
-
Context is setup, so it usually belongs in a `before` block — a group of
|
|
426
|
-
examples sharing one identity says who they are once:
|
|
234
|
+
Context is setup, so it usually belongs in a `before` block — a group of examples
|
|
235
|
+
sharing one identity says who they are once:
|
|
427
236
|
|
|
428
237
|
```ruby
|
|
429
238
|
describe "as the owner", graphql: :in_process do
|
|
430
239
|
before { graphql_context(current_user: alice) }
|
|
431
|
-
|
|
432
|
-
it "shows the drafts" do
|
|
433
|
-
expect(DraftsQuery.execute!.drafts.size).to eq 2
|
|
434
|
-
end
|
|
435
240
|
end
|
|
436
241
|
```
|
|
437
242
|
|
|
438
|
-
The reset runs ahead of any group hook, so each example re-applies that
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
Called with nothing it reads the context back. Under `graphql: :fake` it
|
|
448
|
-
refuses: there are no resolvers to receive a context, and silently ignoring
|
|
449
|
-
one would leave an example asserting on data nothing scoped. Pin the data
|
|
450
|
-
itself instead — `graphql_fake("Person.name" => "Ada")`, above.
|
|
243
|
+
The reset runs ahead of any group hook, so each example re-applies that `before`
|
|
244
|
+
from the same baseline rather than stacking onto the last one's context. Pass a
|
|
245
|
+
block to scope it for the example that needs two identities —
|
|
246
|
+
`graphql_context(admin: true) { … }` — and call it with nothing to read the
|
|
247
|
+
context back. Under `graphql: :fake` it refuses: there are no resolvers to
|
|
248
|
+
receive a context, and silently ignoring one would leave an example asserting on
|
|
249
|
+
data nothing scoped. Pin the data itself instead.
|
|
451
250
|
|
|
452
251
|
## A federated graph — `graphql: :router`
|
|
453
252
|
|
|
454
253
|
Same thing across a federated graph: the tag builds a
|
|
455
|
-
[`Testing::Router`](federation.md#the-local-router), which plans the query
|
|
456
|
-
|
|
457
|
-
|
|
254
|
+
[`Testing::Router`](federation.md#the-local-router), which plans the query across
|
|
255
|
+
your subgraphs and runs it against those **real resolvers** — no gateway, no
|
|
256
|
+
node, no sockets.
|
|
458
257
|
|
|
459
258
|
```ruby
|
|
460
259
|
describe "the dashboard", graphql: :router do
|
|
@@ -469,19 +268,18 @@ A router is built once per supergraph (parsing one per example would be real
|
|
|
469
268
|
time) and stands in for that graph's modules; its context is reset from
|
|
470
269
|
`config.context` every time.
|
|
471
270
|
|
|
472
|
-
`graphql_router` is the tag with options, the way `graphql_fake` is — one
|
|
473
|
-
|
|
271
|
+
`graphql_router` is the tag with options, the way `graphql_fake` is — one option,
|
|
272
|
+
`fake:`, saying how the subgraphs the router
|
|
474
273
|
[fakes](federation.md#a-supergraph-only-partly-local) fabricate: the pins and
|
|
475
|
-
options `graphql_fake` takes, in one hash
|
|
274
|
+
options `graphql_fake` takes, in one hash.
|
|
476
275
|
|
|
477
276
|
```ruby
|
|
478
277
|
graphql_router(fake: { "Shipment.carrier" => "UPS", list_size: 2 })
|
|
479
278
|
```
|
|
480
279
|
|
|
481
|
-
It names no schema, so with more than one graph it refuses: the tag alone
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
suite.
|
|
280
|
+
It names no schema, so with more than one graph it refuses: the tag alone already
|
|
281
|
+
routes each module through its own graph's supergraph, and `config.router = {
|
|
282
|
+
fake: … }` says how the faked subgraphs fabricate for the suite.
|
|
485
283
|
|
|
486
284
|
What it plans, what it **refuses** and why, how subgraphs are matched to your
|
|
487
285
|
schema classes, and what to do about a supergraph only partly local:
|
|
@@ -505,21 +303,14 @@ a production router
|
|
|
505
303
|
|
|
506
304
|
`path` survives; nothing else about the subgraph does — including the
|
|
507
305
|
`extensions.code` your own subgraph set, since it is a subgraph like any other.
|
|
508
|
-
So assert on `path` and on what your app does with the failure, not on a
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
[`Failure`](#simulating-failures), which reproduces it exactly:
|
|
306
|
+
So assert on `path` and on what your app does with the failure, not on a message,
|
|
307
|
+
a code, or the `service` stamp. An example that needs the redacted shape gets it
|
|
308
|
+
from [`Failure`](#simulating-failures), which reproduces it exactly:
|
|
512
309
|
|
|
513
310
|
```ruby
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
"Subgraph errors redacted", path: ["product", "shippingEstimate"],
|
|
518
|
-
),
|
|
519
|
-
upc: "p1",
|
|
520
|
-
)
|
|
521
|
-
expect(response.errors.first.path).to eq ["product", "shippingEstimate"]
|
|
522
|
-
end
|
|
311
|
+
GraphWeaver::Testing::Failure.graphql(
|
|
312
|
+
"Subgraph errors redacted", path: ["product", "shippingEstimate"],
|
|
313
|
+
)
|
|
523
314
|
```
|
|
524
315
|
|
|
525
316
|
## Over the wire — `graphql: :wire`
|
|
@@ -528,13 +319,8 @@ Your schema, served at the endpoint your own client posts to — with
|
|
|
528
319
|
**`GraphWeaver.client` left exactly where it is**. So the request really is
|
|
529
320
|
serialized, posted through your middleware, answered at the far end, and read
|
|
530
321
|
back by `from_h` over the server's own bytes. That is the half the other three
|
|
531
|
-
tags skip: they sit *in* the client slot, so the transport your app ships —
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
**It needs [webmock](https://github.com/bblimke/webmock) and
|
|
535
|
-
[rack](https://github.com/rack/rack)** in the Gemfile (`group :test`) — webmock
|
|
536
|
-
hooks Net::HTTP, Faraday and HTTPX underneath, and its `to_rack` builds the Rack
|
|
537
|
-
env with rack.
|
|
322
|
+
tags skip: they sit *in* the client slot, so the transport your app ships — APM
|
|
323
|
+
tracing, a caller tag, mTLS — never runs.
|
|
538
324
|
|
|
539
325
|
```ruby
|
|
540
326
|
it "sends the caller tag", graphql: :wire do
|
|
@@ -545,21 +331,33 @@ it "sends the caller tag", graphql: :wire do
|
|
|
545
331
|
end
|
|
546
332
|
```
|
|
547
333
|
|
|
548
|
-
|
|
549
|
-
|
|
334
|
+
**It needs [webmock](https://github.com/bblimke/webmock) and
|
|
335
|
+
[rack](https://github.com/rack/rack)** in the Gemfile (`group :test`), and
|
|
336
|
+
webmock has to be *enabled* — `require "webmock/rspec"` in the spec helper, in
|
|
337
|
+
either order with `graph_weaver/rspec`. Having it in the Gemfile is not enough:
|
|
338
|
+
`Bundler.require` loads webmock without installing its adapters, so `:wire`
|
|
339
|
+
checks and refuses *before* the first request rather than letting it leave the
|
|
340
|
+
suite. The tag adds one stub per endpoint and takes each back after the example —
|
|
341
|
+
it never disables net connections on your behalf, and never resets stubs it
|
|
342
|
+
didn't make.
|
|
343
|
+
|
|
344
|
+
**Every endpoint an example can reach is served**, one per graph: the `client`
|
|
345
|
+
each [declared graph](getting_started.md#more-than-one-schema) names, or
|
|
346
|
+
`GraphWeaver.client` for a graph naming none — so a billing module posts to
|
|
347
|
+
billing's url and is answered by billing's schema. What
|
|
348
|
+
sits behind each is **what that graph is**, in descending faithfulness: that
|
|
550
349
|
graph's [router](#a-federated-graph--graphql-router) when it is in a composed
|
|
551
350
|
supergraph, its [live schema class](#real-resolvers--graphql-in_process) when it
|
|
552
|
-
has one, else a [fake](#fabricated-data--graphql-fake) of its schema. So
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
for you, and it is invisible from inside the example. One line per endpoint, at
|
|
351
|
+
has one, else a [fake](#fabricated-data--graphql-fake) of its schema. So an app
|
|
352
|
+
that is a pure *client* of someone else's API gets a schema-correct server
|
|
353
|
+
without writing one. A graph with no schema at all is refused, **by `:wire`'s own
|
|
354
|
+
name** — the one fallback the other tags have and this one can't use is your
|
|
355
|
+
client's own schema, since reading it means introspecting the endpoint `:wire`
|
|
356
|
+
has just stubbed. Commit a dump, or set `config.schema`. A graph whose `client`
|
|
357
|
+
posts nowhere is refused by name too.
|
|
358
|
+
|
|
359
|
+
**It says which, on the logger** — the choice is the one thing this tag makes for
|
|
360
|
+
you, and it is invisible from inside the example. One line per endpoint, at
|
|
563
361
|
`info` (a Rails app already has a logger; elsewhere set `GraphWeaver.logger`):
|
|
564
362
|
|
|
565
363
|
```
|
|
@@ -567,50 +365,22 @@ graph_weaver: :wire serving Shop::Schema (in-process) at https://api.example.com
|
|
|
567
365
|
```
|
|
568
366
|
|
|
569
367
|
When a **fake** stands in while the process has a `GraphQL::Schema` class that
|
|
570
|
-
nothing named, that line is a `warn` instead and names the class
|
|
571
|
-
worth catching, because an
|
|
572
|
-
against fabricated data with
|
|
368
|
+
nothing named, that line is a `warn` instead and names the class, telling you to
|
|
369
|
+
set `GraphWeaver::Testing.config.schema` — the case worth catching, because an
|
|
370
|
+
app that owns real resolvers otherwise goes green against fabricated data with
|
|
371
|
+
nothing said. A warning rather than a refusal, because a loaded class isn't proof
|
|
372
|
+
you meant it *here* — a federated suite loads every subgraph's — and a fake
|
|
373
|
+
behind the wire is a thing to want.
|
|
573
374
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
A warning rather than a refusal, because a loaded class isn't proof you meant
|
|
581
|
-
it *here* — a federated suite loads every subgraph's — and a fake behind the
|
|
582
|
-
wire is a thing to want. Name the class for the suite, and call `graphql_fake`
|
|
583
|
-
in the examples that want fabricated data.
|
|
375
|
+
**A helper says what goes behind the wire.** Under the other tags a `graphql_*`
|
|
376
|
+
helper takes the client slot; under `:wire` it is served instead — the client
|
|
377
|
+
slot has to keep your own client for the transport to run at all — so
|
|
378
|
+
`graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])` reads exactly as
|
|
379
|
+
it does under `:fake`, and the helper returns the client it serves, so
|
|
380
|
+
`fake.requests` is what the *endpoint* was asked.
|
|
584
381
|
|
|
585
|
-
**
|
|
586
|
-
|
|
587
|
-
the client slot has to keep your own client for the transport to run at all —
|
|
588
|
-
so pins read exactly as they do under `:fake`:
|
|
589
|
-
|
|
590
|
-
```ruby
|
|
591
|
-
it "renders two orders, through our own transport", graphql: :wire do
|
|
592
|
-
graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])
|
|
593
|
-
|
|
594
|
-
expect(DashboardQuery.execute!.reader.orders.size).to eq 2
|
|
595
|
-
end
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
It returns the client it serves, so `fake.requests` is what the *endpoint* was
|
|
599
|
-
asked. `graphql_router(fake: …)` and `graphql_in_process(Reviews::Schema)` say
|
|
600
|
-
the same thing for the other two.
|
|
601
|
-
|
|
602
|
-
**Every endpoint an example can reach is served**, one per graph: the client
|
|
603
|
-
each [declared graph](getting_started.md#more-than-one-schema) bakes into its
|
|
604
|
-
modules with `client:`, or `GraphWeaver.client` for a graph that bakes none —
|
|
605
|
-
so an app whose graphs all bake one needs no app default at all. Each gets
|
|
606
|
-
that graph's own schema behind it, so a billing module posts to billing's
|
|
607
|
-
url and is answered by billing's schema. A graph whose baked client posts
|
|
608
|
-
nowhere is refused by name, rather than its requests quietly leaving the
|
|
609
|
-
suite.
|
|
610
|
-
|
|
611
|
-
**Identity comes from the request.** A `context:` **proc** is called per
|
|
612
|
-
request with the headers as sent, which is the seam nothing above the wire can
|
|
613
|
-
test:
|
|
382
|
+
**Identity comes from the request.** A `context:` **proc** is called per request
|
|
383
|
+
with the headers as sent, which is the seam nothing above the wire can test:
|
|
614
384
|
|
|
615
385
|
```ruby
|
|
616
386
|
GraphWeaver::Testing.configure do |config|
|
|
@@ -621,46 +391,36 @@ end
|
|
|
621
391
|
A hash still works, and is still the baseline `graphql_context` merges onto; a
|
|
622
392
|
proc replaces it, and `graphql_context` then says so rather than merging onto
|
|
623
393
|
something that isn't there. (Rack drops a header's capitalization, so `X-CALLER`
|
|
624
|
-
arrives as `X-Caller`.)
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
tag stubs this example's endpoints in a `before` hook of its own.
|
|
629
|
-
|
|
630
|
-
**And webmock has to be *enabled*** — `require "webmock/rspec"` in the spec
|
|
631
|
-
helper, in either order with `graph_weaver/rspec`. Having it in the Gemfile is
|
|
632
|
-
not enough: `Bundler.require` loads webmock without installing its adapters, so
|
|
633
|
-
`:wire` checks and refuses *before* the first request rather than letting it
|
|
634
|
-
leave the suite. That is what makes this a *transport* test rather than a mock
|
|
635
|
-
of one — every transport [documented here](transports.md) runs unchanged,
|
|
636
|
-
pooling and all. The tag adds one stub per endpoint and takes each back after
|
|
637
|
-
the example — it never disables net connections on your behalf, and never
|
|
638
|
-
resets stubs it didn't make.
|
|
394
|
+
arrives as `X-Caller`.) Like every suite setting, it goes in `Testing.configure`
|
|
395
|
+
or an `around` — [never a plain `before`](#nothing-to-configure), `:wire` least
|
|
396
|
+
of all, since the tag stubs this example's endpoints in a `before` hook of its
|
|
397
|
+
own.
|
|
639
398
|
|
|
640
399
|
**The wire adds a hop, not a capability.** Behind a router, everything
|
|
641
|
-
[it refuses](federation.md#what-it-refuses) is still refused, before any
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
[cassette](cassettes.md).
|
|
400
|
+
[it refuses](federation.md#what-it-refuses) is still refused, before any resolver
|
|
401
|
+
runs. Behind a fake, what you are testing is your *transport* — the request your
|
|
402
|
+
middleware wrote, the headers it sent, the retry it does on a 500, and that
|
|
403
|
+
`from_h` reads real JSON off a socket. What it can't tell you is whether your
|
|
404
|
+
`cast:` agrees with the real server: the fabricated bytes are written to match
|
|
405
|
+
your own [scalar registrations](scalars.md), so the round trip agrees with
|
|
406
|
+
itself. Put the live schema class behind the wire for that, or pin a real
|
|
407
|
+
response with a [cassette](cassettes.md).
|
|
650
408
|
|
|
651
409
|
`GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
|
|
652
|
-
satisfies the [client contract](transports.md)
|
|
653
|
-
rather have a real
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
410
|
+
satisfies the [client contract](transports.md), so mount it yourself
|
|
411
|
+
(`run GraphWeaver::Testing::Endpoint.new(router)`) if you'd rather have a real
|
|
412
|
+
socket. A client answering a `context:` **proc** is served **one request at a
|
|
413
|
+
time** — answering one means setting the client's context for the length of that
|
|
414
|
+
dispatch, so the identity a request asked for is the identity it gets, whatever
|
|
415
|
+
else is in flight. The lock is the client's own, so it holds however the endpoint
|
|
416
|
+
is mounted. A `context:` hash is served **concurrently**: nothing writes it, so
|
|
417
|
+
there is nothing to serialize.
|
|
658
418
|
|
|
659
419
|
### Making the served endpoint fail
|
|
660
420
|
|
|
661
|
-
The tag adds **one stub per endpoint**, and webmock answers with the *last*
|
|
662
|
-
|
|
663
|
-
|
|
421
|
+
The tag adds **one stub per endpoint**, and webmock answers with the *last* stub
|
|
422
|
+
declared for a url — so an example that wants the server to misbehave declares
|
|
423
|
+
its own, and it wins for that example:
|
|
664
424
|
|
|
665
425
|
```ruby
|
|
666
426
|
it "surfaces a 503, after the retries it's allowed", graphql: :wire do
|
|
@@ -673,33 +433,28 @@ it "surfaces a 503, after the retries it's allowed", graphql: :wire do
|
|
|
673
433
|
}
|
|
674
434
|
end
|
|
675
435
|
|
|
676
|
-
|
|
677
|
-
stub_request(:post, "https://api.example.com/graphql").to_timeout
|
|
678
|
-
|
|
679
|
-
expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::TransportError)
|
|
680
|
-
end
|
|
436
|
+
# .to_timeout instead, and the call raises GraphWeaver::TransportError
|
|
681
437
|
```
|
|
682
438
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
genuinely slow server — the [`Endpoint`](#over-the-wire--graphql-wire) above on
|
|
689
|
-
a real port, or a `TCPServer` that dawdles before it replies.
|
|
690
|
-
|
|
691
|
-
That is a **served** failure: your transport reads the status and the headers
|
|
692
|
-
off a real response, and your `retries:` budget really spends itself against it
|
|
693
|
-
— including [the one attempt a mutation gets](transports.md#retries). That is
|
|
694
|
-
the half a [`Failure` client](#simulating-failures) can't reach, since those sit
|
|
695
|
-
*in* the client slot and raise above the wire. Use `Failure.server` /
|
|
439
|
+
That is a **served** failure: your transport reads the status and the headers off
|
|
440
|
+
a real response, and your `retries:` budget really spends itself against it —
|
|
441
|
+
including [the one attempt a mutation gets](transports.md#retries). That is the
|
|
442
|
+
half a [`Failure` client](#simulating-failures) can't reach, since those sit *in*
|
|
443
|
+
the client slot and raise above the wire. Use `Failure.server` /
|
|
696
444
|
`Failure.timeout` when the example is about your `rescue`; a stub when it is
|
|
697
445
|
about the transport.
|
|
698
446
|
|
|
447
|
+
`to_timeout` raises instantly, so it tests what your app *does* with a timeout —
|
|
448
|
+
not that a `read_timeout:` of yours is short enough. Sleeping inside
|
|
449
|
+
`to_return { |req| … }` doesn't either: webmock stands in for the socket, so
|
|
450
|
+
there is nothing to time out and the call simply takes that long and succeeds. A
|
|
451
|
+
timeout *value* can only be proven against a genuinely slow server — the
|
|
452
|
+
`Endpoint` above on a real port, or a `TCPServer` that dawdles before it replies.
|
|
453
|
+
|
|
699
454
|
## Simulating failures
|
|
700
455
|
|
|
701
|
-
Every failure mode is just a client, so error-handling paths are testable
|
|
702
|
-
|
|
456
|
+
Every failure mode is just a client, so error-handling paths are testable without
|
|
457
|
+
a server that misbehaves on cue:
|
|
703
458
|
|
|
704
459
|
```ruby
|
|
705
460
|
Failure = GraphWeaver::Testing::Failure
|
|
@@ -732,11 +487,10 @@ GraphWeaver::Testing::FakeClient.new(schema:, fail_at: "people.2.pets.name")
|
|
|
732
487
|
```
|
|
733
488
|
|
|
734
489
|
`Failure.graphql` is the **whole response** failing — `data` is null unless you
|
|
735
|
-
pass `data:`, which is what makes it a partial one. Shape the error by naming
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
convention](errors.md#what-your-server-can-send) is one call:
|
|
490
|
+
pass `data:`, which is what makes it a partial one. Shape the error by naming its
|
|
491
|
+
wire fields beside the message (`code:`, `extensions:`, `path:`, `locations:` —
|
|
492
|
+
anything else is refused rather than swallowed), so a rejection that follows the
|
|
493
|
+
[`extensions.input` convention](errors.md#what-your-server-can-send) is one call:
|
|
740
494
|
|
|
741
495
|
```ruby
|
|
742
496
|
# a plain code, the coarse bucket every server states
|
|
@@ -757,31 +511,134 @@ Failure.graphql({ message: "boom", path: ["person"] }, "and again", data: { "per
|
|
|
757
511
|
|
|
758
512
|
**A server's own rejection lands in `#errors` unless it marked it.** A
|
|
759
513
|
graphql-ruby `validates:` failure carries no `extensions` at all, so it is an
|
|
760
|
-
ordinary error in `response.errors` — `#input_errors` is empty, because
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
the `Failure.graphql(code:, extensions:)` call above is the shape that says it.
|
|
514
|
+
ordinary error in `response.errors` — `#input_errors` is empty, because "the
|
|
515
|
+
value was out of range" and "the database is down" are the same bytes. Assert on
|
|
516
|
+
`errors` for that, and reach for `#input_errors` only once your server
|
|
517
|
+
[says the error is about the input](errors.md#when-the-server-rejects-the-input).
|
|
765
518
|
|
|
766
519
|
## Capture and replay
|
|
767
520
|
|
|
768
|
-
|
|
769
|
-
transport (no HTTP
|
|
521
|
+
`GraphWeaver::Testing.cassette("github", client: live)` returns a client that
|
|
522
|
+
records real API responses and replays them offline, above the transport (no HTTP
|
|
523
|
+
interception). Re-record with `GRAPHWEAVER_RECORD=1`, and set
|
|
524
|
+
`config.anonymize = true` so the response is scrubbed on its way to disk. The
|
|
525
|
+
full workflow guide is **[cassettes](cassettes.md)**.
|
|
526
|
+
|
|
527
|
+
## Nothing to configure
|
|
528
|
+
|
|
529
|
+
Each mode works out what to run against **per graph** — with more than one, the
|
|
530
|
+
honest answer varies per module — and **refuses, naming what it looked for,
|
|
531
|
+
rather than guessing**:
|
|
532
|
+
|
|
533
|
+
- **the schema** is `config.schema` if you set one, else the one that
|
|
534
|
+
[graph](getting_started.md#more-than-one-schema) names, else the committed dump
|
|
535
|
+
at `GraphWeaver.schema_path`, else the schema `GraphWeaver.client` talks to. A
|
|
536
|
+
fake reads the scalar registrations of the graph it is answering, so it invents
|
|
537
|
+
the wire value that graph's generated cast expects. (Pins and `overrides:` stay
|
|
538
|
+
suite-wide, keyed by scalar name — one `"Money"` override for the run.)
|
|
539
|
+
- **`:in_process`** needs the live schema *class*, since only that has resolvers:
|
|
540
|
+
the one that graph names, else the one your client already runs in-process,
|
|
541
|
+
else the loaded class that defines everything the schema declares — the same
|
|
542
|
+
derive-verify-refuse rule that
|
|
543
|
+
[maps subgraphs](federation.md#which-schema-serves-which-subgraph).
|
|
544
|
+
- **`:router`** plans against the composed supergraph **that graph** names, else
|
|
545
|
+
`config.router = { supergraph: … }`, else the committed dump when *that*
|
|
546
|
+
carries `@join__*` markers, else the dump your own client was built from
|
|
547
|
+
(`GraphWeaver.new("supergraph.graphql")`) — which for a federated app is
|
|
548
|
+
usually no config at all. A graph that is in no supergraph is refused **by
|
|
549
|
+
name**, rather than planned against another graph's. A client's *schema* can't
|
|
550
|
+
stand in for one — it is the API schema the router serves, with the `@join__*`
|
|
551
|
+
routing table stripped out — but the file it was read from carries the table.
|
|
552
|
+
Subgraphs are derived either way.
|
|
553
|
+
|
|
554
|
+
So configure only to override a derivation, or to tune fabricated values — in the
|
|
555
|
+
same file as the require, since support files load in sorted order and one naming
|
|
556
|
+
`GraphWeaver::Testing` before it dies on `NameError`:
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
GraphWeaver::Testing.configure do |config|
|
|
560
|
+
# config.schema = MySchema # the live class, rather than the dump
|
|
561
|
+
# config.router = { supergraph: Rails.root.join("supergraph.graphql") }
|
|
562
|
+
# config.router = { subgraphs: { "reviews" => :fake } } # either key alone
|
|
563
|
+
# config.context = { tenant: } # baseline context every example starts from
|
|
564
|
+
# config.default_mode = :fake # what an UNtagged example runs against;
|
|
565
|
+
# # :live (the default) leaves your client
|
|
566
|
+
# # alone, and graphql: :live opts one out
|
|
567
|
+
# config.seed = 4242 # defaults to rspec's own --seed
|
|
568
|
+
# config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
|
|
569
|
+
# config.list_size = 1..3
|
|
570
|
+
end
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**The rule: a helper sets the stand-in for the graph it names; the tag sets the
|
|
574
|
+
mode for every graph no helper named.** `graphql_fake`, `graphql_in_process` and
|
|
575
|
+
`graphql_router` stand in for the modules of the graph their schema names — your
|
|
576
|
+
only graph when they name none — and with more than one, `graph:` says which:
|
|
577
|
+
`graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. A schema
|
|
578
|
+
class names its graph and its schema in one word
|
|
579
|
+
(`graphql_in_process(Reviews::Schema)`), but only for a graph that runs that
|
|
580
|
+
class in-process; `graph:` is the handle every graph has. Naming none they could
|
|
581
|
+
reach is refused, naming your graphs.
|
|
582
|
+
|
|
583
|
+
So one example can run two graphs in two modes — the federated one through its
|
|
584
|
+
router, the plain one faked — and neither helper disturbs the other's graph:
|
|
770
585
|
|
|
771
586
|
```ruby
|
|
772
|
-
|
|
773
|
-
|
|
587
|
+
it "renders the dashboard", graphql: :router do
|
|
588
|
+
graphql_fake(graph: :countries, "Country.name" => "Canada")
|
|
589
|
+
# :storefront routes through its supergraph (the tag); :countries is faked
|
|
590
|
+
end
|
|
774
591
|
```
|
|
775
592
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
593
|
+
A helper that speaks for the whole example *is* refused, though: with one graph,
|
|
594
|
+
or with no `graph:`/schema to narrow it, `graphql: :fake` plus
|
|
595
|
+
`graphql_in_process` is two answers to one question, and the later one winning
|
|
596
|
+
silently would hide which was the mistake.
|
|
597
|
+
|
|
598
|
+
Anything whose honest answer differs per example belongs on the fake instead —
|
|
599
|
+
`graphql_fake(null_chance: 1.0)` for the example that's about an empty state,
|
|
600
|
+
`graphql_fake(values: :literal)` for the one that reads better without faker's
|
|
601
|
+
prose. A suite-wide `null_chance` would sprinkle nils through every *other*
|
|
602
|
+
example, one run in ten, on a seed the failure doesn't name.
|
|
603
|
+
|
|
604
|
+
**Configure at load, or in an `around` — never in a plain `before`.** The tag
|
|
605
|
+
builds this example's clients in a `before` hook of its own, and rspec runs that
|
|
606
|
+
one ahead of yours, so a `before` setting `config.schema`, `config.router` or
|
|
607
|
+
`config.context` arrives after the decision it meant to change. It is
|
|
608
|
+
**refused**, not ignored — a green example running against the wrong stand-in is
|
|
609
|
+
the expensive outcome.
|
|
610
|
+
|
|
611
|
+
### Fabricated list lengths
|
|
612
|
+
|
|
613
|
+
`list_size` is how long an **unbounded** list is — an Integer exactly that many,
|
|
614
|
+
a Range randomized within it, or a Hash saying it per list. A list with a
|
|
615
|
+
`first:`/`last:`/`limit:` argument is that long instead, whatever this says.
|
|
616
|
+
|
|
617
|
+
**Every list the fabricator reaches reads the same setting, so nested lists
|
|
618
|
+
multiply.** A query selecting `rows { owner { … } tags }` with `tags` uncapped
|
|
619
|
+
fabricates `list_size` rows and `list_size` tags *in each of them* — at 1600 that
|
|
620
|
+
is 2.5M tags, and three nested lists cube it. Say it per list instead, keyed the
|
|
621
|
+
way a pin is (a `"Type.field"` coordinate or a bare field name), with `default:`
|
|
622
|
+
for the rest — `config.list_size = { "Row.tags" => 3, default: 1000 }` holds the
|
|
623
|
+
inner list at 3 however large the outer one grows. Or cap it in the query
|
|
624
|
+
(`tags(first: 3)`), where the query is yours to change.
|
|
625
|
+
|
|
626
|
+
**A list field whose name ends in `errors` fabricates empty** — `userErrors`,
|
|
627
|
+
`errors`, `mutationErrors`. The Relay/Shopify payload
|
|
628
|
+
(`placeOrder { order userErrors }`) is the ecosystem's mutation shape, and a
|
|
629
|
+
fabricated order beside a fabricated failure is a response no server can send;
|
|
630
|
+
[pin it](#pins) to write the failure path
|
|
631
|
+
(`{ "userErrors" => [{ "message" => "Out of stock" }] }`).
|
|
632
|
+
|
|
633
|
+
Need the schema itself inside an example — to sample a field, or build a query on
|
|
634
|
+
the fly? The client in play exposes it as `GraphWeaver.client.schema`, and
|
|
635
|
+
`GraphWeaver::Testing.config.schema` reads back what `config.schema =` set,
|
|
636
|
+
falling back to the committed dump.
|
|
780
637
|
|
|
781
638
|
## Test-only generated modules
|
|
782
639
|
|
|
783
|
-
They don't have to live in `app/` — `generated_paths` is an appendable list,
|
|
784
|
-
|
|
640
|
+
They don't have to live in `app/` — `generated_paths` is an appendable list, so a
|
|
641
|
+
support file can register a spec-local set:
|
|
785
642
|
|
|
786
643
|
```ruby
|
|
787
644
|
# spec/support/graph_weaver.rb
|
|
@@ -793,5 +650,5 @@ Both lines matter. In Rails the Railtie loads generated modules during boot,
|
|
|
793
650
|
which is finished before `spec/support/*.rb` runs — so a path appended here is
|
|
794
651
|
never loaded unless you load it. And keep the directory *outside*
|
|
795
652
|
`spec/support/`: rspec-rails requires every `spec/support/**/*.rb` itself, in
|
|
796
|
-
sorted order, so a generated module gets required before the shared `types.rb`
|
|
797
|
-
|
|
653
|
+
sorted order, so a generated module gets required before the shared `types.rb` it
|
|
654
|
+
needs and dies on `LoadError`.
|