graph_weaver 0.6.1 → 0.7.0
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/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- metadata +56 -1
data/docs/testing.md
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
3
|
How to run a spec that executes a GraphQL query without a server — against
|
|
4
|
-
fabricated data, against your own resolvers,
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
fabricated data, against your own resolvers, across a federated graph, or
|
|
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.
|
|
7
9
|
|
|
8
10
|
One line in your spec helper:
|
|
9
11
|
|
|
10
12
|
```ruby
|
|
13
|
+
# spec/support/graph_weaver.rb — or rails_helper.rb itself
|
|
11
14
|
require "graph_weaver/rspec"
|
|
12
15
|
```
|
|
13
16
|
|
|
14
|
-
(In Rails, put it **above** the `spec/support` glob in `rails_helper.rb` —
|
|
15
|
-
rspec-rails requires those partway through, and a support file mentioning
|
|
16
|
-
`GraphWeaver::Testing` before this line dies on `NameError`.)
|
|
17
|
-
|
|
18
17
|
Then **one tag says what an example runs against** — on the example, or on
|
|
19
18
|
the group it belongs to, since rspec metadata inherits:
|
|
20
19
|
|
|
@@ -25,6 +24,7 @@ end
|
|
|
25
24
|
|
|
26
25
|
it "renders the empty state", graphql: :fake do … end
|
|
27
26
|
it "authorizes drafts", graphql: :in_process do … end
|
|
27
|
+
it "sends the caller tag", graphql: :wire do … end
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
| mode | reach for it when | what it costs |
|
|
@@ -32,30 +32,54 @@ it "authorizes drafts", graphql: :in_process do … end
|
|
|
32
32
|
| [`:fake`](#fabricated-data--graphql-fake) | most unit tests — you need *a* well-shaped response | no resolver code runs |
|
|
33
33
|
| [`:in_process`](#real-resolvers--graphql-in_process) | the point of the test is that your resolver logic works | slower; needs a live schema class |
|
|
34
34
|
| [`:router`](#a-federated-graph--graphql-router) | the same, across a federated graph | needs a composed supergraph; [refuses](federation.md#what-it-refuses) shapes it can't plan faithfully |
|
|
35
|
+
| [`:wire`](#over-the-wire--graphql-wire) | the test is about your own transport — headers, middleware, deserialization | needs webmock and rack, and an http client |
|
|
36
|
+
| `: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 |
|
|
35
37
|
| [cassettes](cassettes.md) | pinning a real server's exact response | must be re-recorded when the query changes |
|
|
36
38
|
|
|
37
|
-
The tag installs
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
The tag installs a stand-in per graph, and every generated module of that
|
|
40
|
+
graph runs against it with no per-test setup — including one generated *with*
|
|
41
|
+
a baked `client:`, since that constant is exactly what the tag means to
|
|
42
|
+
replace. `rspec --tag graphql:router` runs one mode's examples.
|
|
43
|
+
|
|
44
|
+
**A bad variable never gets as far as a mode.** `execute` coerces the variables
|
|
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.
|
|
56
|
+
|
|
57
|
+
**Every example has exactly one mode.** An untagged one takes
|
|
58
|
+
`config.default_mode`, which is `:live` — your own client, exactly as it is —
|
|
59
|
+
unless the suite sets another; and **`graphql: :live` is how one example steps
|
|
60
|
+
back out** of a default the suite did set.
|
|
44
61
|
|
|
45
62
|
`GraphWeaver.client` is **snapshotted before every example and restored
|
|
46
|
-
after** — tagged
|
|
47
|
-
it. So building your own client is a plain assignment, cleaned up like a
|
|
63
|
+
after** — tagged or untagged, whatever its mode, and whatever the example did
|
|
64
|
+
to it. So building your own client is a plain assignment, cleaned up like a
|
|
48
65
|
tagged one:
|
|
49
66
|
|
|
50
67
|
```ruby
|
|
51
68
|
before { GraphWeaver.client = GraphWeaver::Testing::Failure.throttled }
|
|
52
69
|
```
|
|
53
70
|
|
|
54
|
-
Both at once and the
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
Both at once and **the tag wins**: a mode's stand-in outranks
|
|
72
|
+
`GraphWeaver.client=`, so assigning one inside a tagged example does *not*
|
|
73
|
+
change what its generated modules run against — the assignment reads back,
|
|
74
|
+
and the modules keep using the mode. Two things do step out of a tag, and
|
|
75
|
+
`graphql: :live` steps the whole example out:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
DashboardQuery.execute!(client: GraphWeaver::Testing::Failure.throttled) # this call
|
|
79
|
+
DashboardQuery.client = GraphWeaver::Testing::Failure.throttled # this module
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
[Client resolution](transports.md#client-resolution) has the full order.
|
|
59
83
|
|
|
60
84
|
Everything here is a *client* — the one interface queries run through (the
|
|
61
85
|
contract is in [transports](transports.md)). Fakes, the router, failures and
|
|
@@ -71,29 +95,50 @@ DashboardQuery = router.parse("query Dashboard { me { username } }")
|
|
|
71
95
|
DashboardQuery.execute!.me.username
|
|
72
96
|
```
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
[`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb)
|
|
76
|
-
|
|
98
|
+
Every mode, tagged and running end to end, is
|
|
99
|
+
[`spec/rspec_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/rspec_spec.rb) (and
|
|
100
|
+
[`spec/wire_mode_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/wire_mode_spec.rb) for `:wire`) — the
|
|
101
|
+
reference for anything this page leaves out.
|
|
77
102
|
|
|
78
103
|
## Nothing to configure
|
|
79
104
|
|
|
80
|
-
Each mode works out what to run against
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.)
|
|
85
116
|
- **`:in_process`** needs the live schema *class*, since only that has
|
|
86
|
-
resolvers: the one
|
|
87
|
-
class that defines everything the schema
|
|
88
|
-
derive-verify-refuse rule that
|
|
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
|
|
89
120
|
[maps subgraphs](federation.md#which-schema-serves-which-subgraph).
|
|
90
|
-
- **`:router`** plans against the composed supergraph
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
95
133
|
|
|
96
|
-
|
|
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`:
|
|
97
142
|
|
|
98
143
|
```ruby
|
|
99
144
|
GraphWeaver::Testing.configure do |config|
|
|
@@ -101,14 +146,81 @@ GraphWeaver::Testing.configure do |config|
|
|
|
101
146
|
# config.router = { supergraph: Rails.root.join("supergraph.graphql") }
|
|
102
147
|
# config.router = { subgraphs: { "reviews" => :fake } } # either key alone
|
|
103
148
|
# config.context = { tenant: } # baseline context every example starts from
|
|
104
|
-
# config.default_mode = :fake # what an UNtagged example runs against
|
|
105
|
-
# #
|
|
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
|
|
106
152
|
# config.seed = 4242 # defaults to rspec's own --seed
|
|
107
153
|
# config.overrides = { "Money" => "12.00", "Person.name" => "Daniel" }
|
|
108
154
|
# config.list_size = 1..3
|
|
109
155
|
end
|
|
110
156
|
```
|
|
111
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
|
+
|
|
112
224
|
Anything whose honest answer differs per example belongs on the fake instead
|
|
113
225
|
— `graphql_fake(null_chance: 1.0)` for the example that's about an empty
|
|
114
226
|
state, `graphql_fake(values: :literal)` for the one that reads better without
|
|
@@ -164,7 +276,11 @@ graphql_fake("Money" => "12.00", # every Money field, however deep
|
|
|
164
276
|
Keys are schema vocabulary, so they survive query refactors — a type name, or
|
|
165
277
|
`"Type.field"` (a bare `"field"` pins it on every type) — and they are checked
|
|
166
278
|
and spellchecked: `"Person.nmae"` raises rather than quietly pinning nothing
|
|
167
|
-
and leaving the example green against random data.
|
|
279
|
+
and leaving the example green against random data. **Schema vocabulary, not
|
|
280
|
+
Ruby:** a `countries` field generates a `Countries` struct, but the pin is
|
|
281
|
+
`"Country"`, the type name the schema uses — spelled the way *that* schema
|
|
282
|
+
spells it, so a Hasura table type is `"pokemon_v2_pokemon"` and not a
|
|
283
|
+
Ruby-cased guess at it.
|
|
168
284
|
|
|
169
285
|
An **object pin** is anything answering the field names — a FactoryBot build, a
|
|
170
286
|
model, a `Struct`, an `OpenStruct`. For each selected field the fake calls the
|
|
@@ -182,16 +298,43 @@ needs — only `Money.parse` knows what wire value it accepts — so without one
|
|
|
182
298
|
fabrication refuses at the path it reached (`at reader.orders.0.total`) and
|
|
183
299
|
names the pin to add, rather than feeding your cast a placeholder that fails
|
|
184
300
|
deep inside `from_h`. A scalar registered as `BigDecimal`, `Time`, `Date`,
|
|
185
|
-
`Integer`, `Float`, `String` or `T::Boolean` needs nothing.
|
|
301
|
+
`Integer`, `Float`, `String` or `T::Boolean` needs nothing. The key is the
|
|
302
|
+
**schema's scalar name**, not the Ruby class it maps to, so two scalars that
|
|
303
|
+
both deserialize into `Money` want a pin each. Suite-wide, the same hash is
|
|
186
304
|
`config.overrides`, and the [cassette anonymizer](cassettes.md) reads it too.
|
|
187
305
|
|
|
306
|
+
A pin is **what the wire carries** — `"12.00"`, not `Money.parse("12.00")` —
|
|
307
|
+
but the object is accepted wherever the registration can serialize one, which
|
|
308
|
+
is the same rule an object pin's fields already follow. A `serialize:` **Proc**
|
|
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.
|
|
312
|
+
|
|
188
313
|
Pins lead and options follow — `graphql_fake("Money" => "12.00", values:
|
|
189
|
-
:literal)`.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
314
|
+
:literal)`. Pins and options are the same keywords, told apart by a lookup: a
|
|
315
|
+
key the fake takes is an option, a key **your schema** knows is a pin, and a
|
|
316
|
+
key that is neither is refused naming both. So a lowercase type pins as
|
|
317
|
+
readily as a capitalized one — `graphql_fake("pokemon_v2_pokemon" => …)` for a
|
|
318
|
+
Hasura API. `overrides:` takes the same hash by keyword, and the leading pins
|
|
319
|
+
win where both name a key; written as that leading hash a key is only ever a
|
|
320
|
+
pin, which is the spelling for a schema whose own vocabulary collides with an
|
|
321
|
+
option name. The refusal is the same at every door: `FakeClient.new`,
|
|
322
|
+
`graphql_fake`, `Router.new(fake:)` and `graphql_router(fake:)`.
|
|
323
|
+
|
|
324
|
+
**A pin answers every call the same way**, which is how a paging loop fed by a
|
|
325
|
+
fake runs forever — page two is as full as page one.
|
|
326
|
+
`GraphWeaver::Testing::Sequence` chains clients and repeats the last, so an
|
|
327
|
+
empty pin on the second one is what ends the loop:
|
|
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.
|
|
195
338
|
|
|
196
339
|
### The example that's *about* the data
|
|
197
340
|
|
|
@@ -268,10 +411,16 @@ block, and is restored after the example.
|
|
|
268
411
|
### The context your resolvers see
|
|
269
412
|
|
|
270
413
|
`graphql_context` is available in every example. It **merges** onto
|
|
271
|
-
`config.context` — the baseline survives unless you override a key —
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
414
|
+
`config.context` — the baseline survives unless you override a key — reaches
|
|
415
|
+
every stand-in the example runs through (all your graphs', and the ones
|
|
416
|
+
`:wire` serves behind its endpoints), and is **reset before the next
|
|
417
|
+
example**, so one example running as somebody else can't leak into the one
|
|
418
|
+
after it.
|
|
419
|
+
|
|
420
|
+
It is also the *only* way to set a context from inside an example.
|
|
421
|
+
`config.context` is the suite baseline, read when an example's clients are
|
|
422
|
+
built — before any `before` hook runs — so setting it there is refused rather
|
|
423
|
+
than silently dropped.
|
|
275
424
|
|
|
276
425
|
Context is setup, so it usually belongs in a `before` block — a group of
|
|
277
426
|
examples sharing one identity says who they are once:
|
|
@@ -316,9 +465,9 @@ describe "the dashboard", graphql: :router do
|
|
|
316
465
|
end
|
|
317
466
|
```
|
|
318
467
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
468
|
+
A router is built once per supergraph (parsing one per example would be real
|
|
469
|
+
time) and stands in for that graph's modules; its context is reset from
|
|
470
|
+
`config.context` every time.
|
|
322
471
|
|
|
323
472
|
`graphql_router` is the tag with options, the way `graphql_fake` is — one
|
|
324
473
|
option, `fake:`, saying how the subgraphs the router
|
|
@@ -329,10 +478,224 @@ options `graphql_fake` takes, in one hash:
|
|
|
329
478
|
graphql_router(fake: { "Shipment.carrier" => "UPS", list_size: 2 })
|
|
330
479
|
```
|
|
331
480
|
|
|
481
|
+
It names no schema, so with more than one graph it refuses: the tag alone
|
|
482
|
+
already routes each module through its own graph's supergraph, and
|
|
483
|
+
`config.router = { fake: … }` says how the faked subgraphs fabricate for the
|
|
484
|
+
suite.
|
|
485
|
+
|
|
332
486
|
What it plans, what it **refuses** and why, how subgraphs are matched to your
|
|
333
487
|
schema classes, and what to do about a supergraph only partly local:
|
|
334
488
|
**[federation → the local router](federation.md#the-local-router)**.
|
|
335
489
|
|
|
490
|
+
### Production redacts what this router hands you
|
|
491
|
+
|
|
492
|
+
A subgraph's error reaches you here in full — its message, and
|
|
493
|
+
`extensions: {"service" => "<subgraph>"}` saying which subgraph produced it. So
|
|
494
|
+
does a dev router configured with `include_subgraph_errors.all: true`. A
|
|
495
|
+
production Apollo Router, with that setting **omitted** — the default — replaces
|
|
496
|
+
the message and empties the extensions:
|
|
497
|
+
|
|
498
|
+
```
|
|
499
|
+
here, and a dev router
|
|
500
|
+
{"message" => "carrier unavailable for this weight",
|
|
501
|
+
"path" => ["product", "shippingEstimate"], "extensions" => {"service" => "reviews"}}
|
|
502
|
+
a production router
|
|
503
|
+
{"message" => "Subgraph errors redacted", "path" => ["product", "shippingEstimate"]}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`path` survives; nothing else about the subgraph does — including the
|
|
507
|
+
`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
|
+
message, a code, or the `service` stamp.
|
|
510
|
+
An example that needs the redacted shape gets it from
|
|
511
|
+
[`Failure`](#simulating-failures), which reproduces it exactly:
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
it "degrades when shipping is unavailable" do
|
|
515
|
+
response = ProductQuery.execute(
|
|
516
|
+
client: GraphWeaver::Testing::Failure.graphql(
|
|
517
|
+
"Subgraph errors redacted", path: ["product", "shippingEstimate"],
|
|
518
|
+
),
|
|
519
|
+
upc: "p1",
|
|
520
|
+
)
|
|
521
|
+
expect(response.errors.first.path).to eq ["product", "shippingEstimate"]
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Over the wire — `graphql: :wire`
|
|
526
|
+
|
|
527
|
+
Your schema, served at the endpoint your own client posts to — with
|
|
528
|
+
**`GraphWeaver.client` left exactly where it is**. So the request really is
|
|
529
|
+
serialized, posted through your middleware, answered at the far end, and read
|
|
530
|
+
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
|
+
APM tracing, a caller tag, mTLS — never runs.
|
|
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.
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
it "sends the caller tag", graphql: :wire do
|
|
541
|
+
DashboardQuery.execute!
|
|
542
|
+
|
|
543
|
+
expect(WebMock).to have_requested(:post, "https://api.example.com/graphql")
|
|
544
|
+
.with(headers: { "X-Caller" => "web" })
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
What sits behind each endpoint is **what that graph is** — decided the way the
|
|
549
|
+
other tags already decide it, **per graph**, in descending faithfulness: that
|
|
550
|
+
graph's [router](#a-federated-graph--graphql-router) when it is in a composed
|
|
551
|
+
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 one
|
|
553
|
+
federated graph doesn't put its router behind a plain graph's url, and an app
|
|
554
|
+
that is a pure *client* of someone else's API — a committed dump and no
|
|
555
|
+
resolvers to serve — gets a schema-correct server without writing one. A graph
|
|
556
|
+
with no schema at all is refused, **by `:wire`'s own name**: the one thing the
|
|
557
|
+
other tags can fall back to and this one can't is your client's own schema,
|
|
558
|
+
since reading it means introspecting the endpoint `:wire` has just stubbed.
|
|
559
|
+
Commit a dump, or set `config.schema`.
|
|
560
|
+
|
|
561
|
+
**It says which, on the logger** — the choice is the one thing this tag makes
|
|
562
|
+
for you, and it is invisible from inside the example. One line per endpoint, at
|
|
563
|
+
`info` (a Rails app already has a logger; elsewhere set `GraphWeaver.logger`):
|
|
564
|
+
|
|
565
|
+
```
|
|
566
|
+
graph_weaver: :wire serving Shop::Schema (in-process) at https://api.example.com/graphql
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
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 — the case
|
|
571
|
+
worth catching, because an app that owns real resolvers otherwise goes green
|
|
572
|
+
against fabricated data with nothing said:
|
|
573
|
+
|
|
574
|
+
```
|
|
575
|
+
graph_weaver: :wire serving a fake at https://api.example.com/graphql — Shop::Schema
|
|
576
|
+
is loaded and nothing named it, so your resolvers did not run. To serve them,
|
|
577
|
+
name it: GraphWeaver::Testing.config.schema = Shop::Schema
|
|
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.
|
|
584
|
+
|
|
585
|
+
**A helper says what goes behind the wire.** Under the other tags a
|
|
586
|
+
`graphql_*` helper takes the client slot; under `:wire` it is served instead —
|
|
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:
|
|
614
|
+
|
|
615
|
+
```ruby
|
|
616
|
+
GraphWeaver::Testing.configure do |config|
|
|
617
|
+
config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
|
|
618
|
+
end
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
A hash still works, and is still the baseline `graphql_context` merges onto; a
|
|
622
|
+
proc replaces it, and `graphql_context` then says so rather than merging onto
|
|
623
|
+
something that isn't there. (Rack drops a header's capitalization, so `X-CALLER`
|
|
624
|
+
arrives as `X-Caller`.)
|
|
625
|
+
|
|
626
|
+
Like every suite setting, it goes in `Testing.configure` or an `around` —
|
|
627
|
+
[never a plain `before`](#nothing-to-configure), `:wire` least of all, since the
|
|
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.
|
|
639
|
+
|
|
640
|
+
**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
|
+
resolver runs. Behind a fake, what you are testing is your *transport* — the
|
|
643
|
+
request your middleware wrote, the headers it sent, the retry it does on a 500,
|
|
644
|
+
and that `from_h` reads real JSON off a socket rather than a Ruby hash you
|
|
645
|
+
handed it. What it can't tell you is whether your `cast:` agrees with the real
|
|
646
|
+
server: the fabricated bytes are written to match your own
|
|
647
|
+
[scalar registrations](scalars.md), so the round trip agrees with itself. Put
|
|
648
|
+
the live schema class behind the wire for that, or pin a real response with a
|
|
649
|
+
[cassette](cassettes.md).
|
|
650
|
+
|
|
651
|
+
`GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
|
|
652
|
+
satisfies the [client contract](transports.md) — so mount it yourself if you'd
|
|
653
|
+
rather have a real socket:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
run GraphWeaver::Testing::Endpoint.new(router) # config.ru, or a Puma in a thread
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### Making the served endpoint fail
|
|
660
|
+
|
|
661
|
+
The tag adds **one stub per endpoint**, and webmock answers with the *last*
|
|
662
|
+
stub declared for a url — so an example that wants the server to misbehave
|
|
663
|
+
declares its own, and it wins for that example:
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
it "surfaces a 503, after the retries it's allowed", graphql: :wire do
|
|
667
|
+
stub_request(:post, "https://api.example.com/graphql")
|
|
668
|
+
.to_return(status: 503, headers: { "Retry-After" => "0" }, body: "down for maintenance")
|
|
669
|
+
|
|
670
|
+
expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::ServerError) { |e|
|
|
671
|
+
expect(e.status).to eq 503
|
|
672
|
+
expect(e.retry_after).to eq 0 # the header your backoff read
|
|
673
|
+
}
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
it "surfaces a timeout", graphql: :wire do
|
|
677
|
+
stub_request(:post, "https://api.example.com/graphql").to_timeout
|
|
678
|
+
|
|
679
|
+
expect { PlaceOrderMutation.execute!(input:) }.to raise_error(GraphWeaver::TransportError)
|
|
680
|
+
end
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
`to_timeout` raises what a timeout raises, instantly — it tests what your app
|
|
684
|
+
*does* with one. It does not test that a `read_timeout:` of yours is short
|
|
685
|
+
enough, and neither does sleeping inside `to_return { |req| … }`: webmock stands
|
|
686
|
+
in for the socket, so there is nothing to time out and the call simply takes
|
|
687
|
+
that long and succeeds. A timeout *value* can only be proven against a
|
|
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` /
|
|
696
|
+
`Failure.timeout` when the example is about your `rescue`; a stub when it is
|
|
697
|
+
about the transport.
|
|
698
|
+
|
|
336
699
|
## Simulating failures
|
|
337
700
|
|
|
338
701
|
Every failure mode is just a client, so error-handling paths are testable
|
|
@@ -342,10 +705,11 @@ without a server that misbehaves on cue:
|
|
|
342
705
|
Failure = GraphWeaver::Testing::Failure
|
|
343
706
|
|
|
344
707
|
PersonQuery.execute(client: Failure.transport, id: "1") # raises TransportError
|
|
708
|
+
PersonQuery.execute(client: Failure.timeout, id: "1") # raises TransportError, cause Net::ReadTimeout
|
|
345
709
|
PersonQuery.execute(client: Failure.server(status: 502), id: "1") # raises ServerError
|
|
346
710
|
PersonQuery.execute(client: Failure.throttled, id: "1") # errors.first.code => "THROTTLED"
|
|
347
711
|
PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
|
|
348
|
-
PersonQuery.execute(client: Failure.graphql("boom"), id: "1") #
|
|
712
|
+
PersonQuery.execute(client: Failure.graphql("boom"), id: "1") # errors, and no data
|
|
349
713
|
|
|
350
714
|
# a throttling server, with the header a backoff reads
|
|
351
715
|
PersonQuery.execute(client: Failure.server(status: 429, headers: { "retry-after" => "2" }), id: "1")
|
|
@@ -356,14 +720,49 @@ fake = GraphWeaver::Testing::FakeClient.new(schema:)
|
|
|
356
720
|
GraphWeaver::Testing::Sequence.new(Failure.transport, Failure.transport, fake)
|
|
357
721
|
|
|
358
722
|
# type mismatch: corrupt: derives a wrong-typed wire value for the field —
|
|
359
|
-
# casting raises GraphWeaver::
|
|
723
|
+
# casting raises GraphWeaver::CastError (overrides remain the manual escape hatch)
|
|
360
724
|
GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")
|
|
361
725
|
|
|
362
726
|
# field-level partial failure with real GraphQL null propagation: the error
|
|
363
|
-
# lands with its concrete path and nulls bubble to the nearest nullable spot
|
|
727
|
+
# lands with its concrete path and nulls bubble to the nearest nullable spot.
|
|
728
|
+
# The path is response keys joined by dots, and a list index is a segment of
|
|
729
|
+
# its own — state only the indices you mean, the rest match any position
|
|
364
730
|
GraphWeaver::Testing::FakeClient.new(schema:, fail_at: { path: "person.email", code: "PRIVATE" })
|
|
731
|
+
GraphWeaver::Testing::FakeClient.new(schema:, fail_at: "people.2.pets.name")
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
`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
|
+
its wire fields beside the message (`code:`, `extensions:`, `path:`,
|
|
737
|
+
`locations:` — anything else is refused rather than swallowed), so a rejection
|
|
738
|
+
that follows the [`extensions.input`
|
|
739
|
+
convention](errors.md#what-your-server-can-send) is one call:
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
# a plain code, the coarse bucket every server states
|
|
743
|
+
Failure.graphql("that input was bad", code: "BAD_USER_INPUT")
|
|
744
|
+
|
|
745
|
+
# the convention: response.input_errors reads this back as one InputError,
|
|
746
|
+
# kind :out_of_range, details { min: 1 }, on path ["input", "min"]
|
|
747
|
+
Failure.graphql(
|
|
748
|
+
"min must be at least 1",
|
|
749
|
+
code: "BAD_USER_INPUT",
|
|
750
|
+
extensions: { "input" => { "kind" => "out_of_range", "path" => ["input", "min"],
|
|
751
|
+
"coordinate" => "RangeInput.min", "value" => 0, "min" => 1 } },
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# several errors, and partial data: each carries its own hash
|
|
755
|
+
Failure.graphql({ message: "boom", path: ["person"] }, "and again", data: { "person" => nil })
|
|
365
756
|
```
|
|
366
757
|
|
|
758
|
+
**A server's own rejection lands in `#errors` unless it marked it.** A
|
|
759
|
+
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
|
+
"the value was out of range" and "the database is down" are the same bytes.
|
|
762
|
+
Assert on `errors` for that, and reach for `#input_errors` only once your
|
|
763
|
+
server [says the error is about the input](errors.md#when-the-server-rejects-the-input);
|
|
764
|
+
the `Failure.graphql(code:, extensions:)` call above is the shape that says it.
|
|
765
|
+
|
|
367
766
|
## Capture and replay
|
|
368
767
|
|
|
369
768
|
Cassettes record real API responses and replay them offline, above the
|