Skip to content

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:

Terminal window
flutter pub add --dev sling_gql_test

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 test

The 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 OK
server.failNext(const MockFailure.graphQL('expired', code: 'UNAUTHENTICATED'));
server.failNext(const MockFailure.network()); // a SlingNetworkException

Failed 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.

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.

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.