Testing
Testing a screen built on sling_gql means answering the document it records.
That document has hashed aliases (launches_c0y93u18lig6v: launches(first: $first))
and variables, so hand-writing responses, or regex-ing the alias out of the
query, gets old fast. sling_gql_test removes that layer:
flutter pub add --dev sling_gql_testMockGraphQLServer
Section titled “MockGraphQLServer”An in-memory server that parses the document the client sends and resolves it
against plain Dart data. Values are maps with __typename (that is what the
normalized cache keys on), lists, scalars — or resolvers, functions that
receive the field’s arguments (variables already substituted):
import 'package:sling_gql_test/sling_gql_test.dart';
final launches = { 'launch-181': { '__typename': 'Launch', 'id': 'launch-181', 'name': 'Starlink 6-45', 'favorite': false, 'rocket': {'__typename': 'Rocket', 'id': 'falcon9', 'name': 'Falcon 9'}, },};
final server = MockGraphQLServer( query: { 'me': {'__typename': 'Viewer', 'id': 'viewer-1', 'name': 'Mira Vance'}, 'launch': (args) => launches[args['id']], 'launches': (args) => { '__typename': 'LaunchConnection', 'nodes': launches.values.take(args['first'] as int).toList(), 'pageInfo': {'__typename': 'PageInfo', 'hasNextPage': false, 'endCursor': null}, 'totalCount': launches.length, }, }, mutation: { 'toggleFavorite': (args) { final launch = launches[args['launchId']]!; launch['favorite'] = !(launch['favorite'] as bool); return launch; }, },);
final client = server.client(Query.root); // disposed at the end of the testThe response contains exactly what was selected, under the aliases the document used. Nothing to compute on your side; change the maps between requests and the next response reflects it.
What the server checks for you, loudly (a StateError, not a silent null):
- a root field the document selects that is not in
query:/mutation:; - a sub-field selected on an object that does not have it (unless
schema:fills it, below); - a selected object without
__typename.
Pass the generated slingSchema to write only the fields a test cares about:
the server fills the other selected fields from their type. Nullable fields
are null, lists [], built-in scalars 'Type.field', 0, 0.0, false,
the key field the object’s path (generated objects never merge in the cache),
an enum its first value, an interface or union its first concrete type, and an
object {'__typename': 'T'}, filled the same way. defaults: gives, by type
name, the value of custom scalars or overrides any of these; for an object
type, the fields its objects start from:
final server = MockGraphQLServer( schema: slingSchema, defaults: { 'DateTime': '2021-01-01T00:00:00Z', 'Rocket': {'name': 'Falcon 9'}, }, query: { 'launch': (args) => {'__typename': 'Launch', 'id': args['id']}, },);A custom scalar missing from defaults: still fails, naming the type; root
fields are never filled. The objects you do write need no __typename when their
field’s type is an object type: the server takes it from the schema. Write it
only for an interface or a union.
Throw MockGraphQLError('message') from a resolver to produce an errors[]
entry with the right path — the field resolves to null, siblings still
resolve, and the client’s partial-error handling kicks in;
MockGraphQLError('who?', code: 'UNAUTHENTICATED') sets extensions.code. Any
other exception propagates and fails the test.
To fail whole requests — error handling, retries, auth refresh — queue
failures with failNext; they apply to the next requests (queries, mutations,
subscription connections) in order, then the server answers normally again:
server.failNext(const MockFailure.status(503), times: 2); // retried, then OKserver.failNext(const MockFailure.graphQL('expired', code: 'UNAUTHENTICATED'));server.failNext(const MockFailure.network()); // a SlingNetworkExceptionFailed requests stay in server.requests (.failed), each with its
.headers (check the authorization a SlingAuth sent).
server.client(Query.root, retry:, timeout:, errorPolicy:, auth:) passes
those on to the client; pass retry: RetryPolicy.none when a test expects
the first failure.
DateTime values are sent as ISO-8601 strings; enums are the GraphQL names
('SUCCESS'). Resolvers may return a Future; latency: delays every
response (on the fake clock in testWidgets).
server.requests is the log: lastRequest.type, .rootFields,
.selects('launches.nodes.name'), .document, .variables — the same
assertions the example app tests make on
request counts and documents.
expect(server.requests, hasLength(1), reason: 'header + list batched');expect(server.lastRequest.selects('launches.nodes.rocket.name'), isTrue);server.httpClient, server.transport and server.subscriptionTransport plug
into a client you construct yourself (SlingClient(httpClient: server.httpClient)), for instance to test a custom
transport in front of it.
subscription: fields are Streams (or resolvers returning one). Each value
is projected to the document’s selection and delivered as one event;
closing the stream completes the subscription. server.openSubscriptions
counts the ones still open — assert 0 after the widget is gone. See
Subscriptions.
pumpUntilSettled
Section titled “pumpUntilSettled”pumpAndSettle() stops when no frame is scheduled, which is before the
response has landed. tester.pumpUntilSettled(client) pumps until the client
has nothing in flight and the last frame caused no new request: every
QueryBuilder on screen has its data (or its error) and has rebuilt with it.
Mutations count too.
testWidgets('list shows the first page', (tester) async { await tester.pumpWidget( SlingScope<Query>(client: client, schema: slingSchema, child: const SlingApp()), ); expect(find.byType(SkeletonBox), findsWidgets);
await tester.pumpUntilSettled(client); expect(find.text('Starlink 6-45'), findsOneWidget); expect(server.requests, hasLength(1));});Each round pumps a frame, advances the fake clock by step (50 ms: server
latency, page transitions), and — only if the client is still busy — lets
real async work run for up to step so a real HTTP request can complete.
After timeout (10 s) it fails the test with a message that says which
situation you are in: a request that never completed, or a frame that causes
a new request every time.
A waterfall (a field read inside an if on fetched data) is followed to the
end: the helper waits for the second round trip too. Check
server.requests.length to catch it.
Under the hood this is SlingClient.isIdle / whenIdle, which you can use
in plain test()s as well.
Against a real server
Section titled “Against a real server”The example app tests run against the
bundled mock API over HTTP. flutter test blocks sockets by default;
useRealNetwork() at the top of main() lifts that for the file, and turns
keep-alive off so an idle connection’s timer is not reported as pending at
the end of every testWidgets — the reason tests used to end with
client.dispose().
void main() { useRealNetwork();
testWidgets('...', (tester) async { final client = SlingClient<Query>( endpoint: Uri.parse('http://localhost:4000/graphql'), schema: slingSchema, ); await tester.pumpWidget(...); await tester.pumpUntilSettled(client); });}disposeAfterTest(client) registers client.dispose() as a tear-down and
returns the client, for plain test()s that hold a real http.Client.