Skip to content

Testing utilities

import 'package:gisila/gisila.dart';
import 'package:gisila/testing.dart';
import 'package:test/test.dart';

package:gisila/testing.dart is a separate library: import it from tests only.

An in-memory HTTP client. Requests go through app.buildHandler(), so guards, forms, middleware and error mapping all run, with no server or socket.

class GisilaTestClient {
GisilaTestClient(GisilaApp app, {Map<String, String> headers = const {}, Uri? baseUri});
GisilaTestClient.handler(Handler handler, {Map<String, String> headers = const {}, Uri? baseUri});
final Map<String, String> headers;
final Uri baseUri;
GisilaTestClient withToken(String token);
GisilaTestClient withHeaders(Map<String, String> extra);
GisilaTestClient as(Principal principal);
Future<TestResponse> get(String path, {Map<String, Object?>? query, Map<String, String>? headers});
Future<TestResponse> head(String path, {...});
Future<TestResponse> options(String path, {...});
Future<TestResponse> post(String path, {Object? json, Map<String, String>? form, Object? body,
String? contentType, Map<String, Object?>? query, Map<String, String>? headers});
Future<TestResponse> put(String path, {...});
Future<TestResponse> patch(String path, {...});
Future<TestResponse> delete(String path, {Object? json, Map<String, Object?>? query, Map<String, String>? headers});
Future<TestResponse> request(String method, String path, {...});
}

When to use: for nearly every controller test. It exercises the real routing, binding and guards while staying fast. Use a real server only to test things that need sockets (client addresses, TLS).

Member Description
GisilaTestClient(app) Builds the app’s handler once, so register controllers, services and singletons first. It does not call init(): do that yourself if you use factory singletons, onStart hooks or a cache.
GisilaTestClient.handler(handler) A client for any Shelf handler. as() is not available.
headers Sent with every request; per-request headers win.
baseUri Scheme and host; defaults to http://localhost.
withToken(token) New client sending Authorization: Bearer <token>.
withHeaders(extra) New client with extra headers. Clients are immutable, so a shared base client can’t leak credentials between tests.
as(principal) New client authenticated as principal. Needs AppConfig(authenticator: TestAuthenticator(...)), otherwise StateError.
request(...) Give at most one body: json (encoded, application/json), form (url-encoded) or raw body (String or bytes) with contentType. query values may be lists for repeated keys; null values are skipped.

Requests have no connection info, so the rate limiter’s default key is the same (unknown) for every test request.

Example

void main() {
late GisilaApp app;
late GisilaTestClient client;
final auth = TestAuthenticator();
setUp(() async {
app = GisilaApp(config: AppConfig(authenticator: auth, logRequests: false));
app.registerService<UserService>(UserService.new);
app.registerController(attacher: UsersController().attachToApp);
await app.init();
client = GisilaTestClient(app);
});
tearDown(() => app.stop());
test('requires users:read', () async {
final res = await client.get('/users/1');
expect(res.statusCode, 401);
expect(res.errorCode, 'unauthorized');
});
test('admins can read users', () async {
final admin = client.as(const Principal(id: 'a', permissions: {'users:read'}));
final res = await admin.get('/users/1');
expect(res.statusCode, 200);
expect(res.jsonMap['id'], '1');
});
test('validates the form', () async {
final res = await client.post('/users', json: {'email': 'nope'});
expect(res.statusCode, 400);
expect(res.errorCode, 'validation_failed');
expect((res.error!['details'] as Map)['email'], isNotNull);
});
}

A fully read response.

class TestResponse {
final int statusCode;
final Map<String, String> headers;
final List<int> bodyBytes;
late final String body;
late final Object? json;
Map<String, Object?> get jsonMap;
List<Object?> get jsonList;
Map<String, Object?>? get error;
String? get errorCode;
String? get errorMessage;
}
Member Description
statusCode Response status.
headers Response headers; lookups ignore case.
bodyBytes, body Raw bytes and UTF-8 text.
json Decoded JSON. Throws FormatException (showing the body) when the body isn’t JSON.
jsonMap, jsonList json as an object or array; StateError otherwise.
error The error object of the gisila envelope, or null.
errorCode, errorMessage error.code and error.message.

An authenticator for tests: tokenFor hands out a bearer token for any principal.

class TestAuthenticator extends Authenticator {
TestAuthenticator({Authenticator fallback = const NullAuthenticator()});
final Authenticator fallback;
String tokenFor(Principal principal);
}

When to use: set it as the app’s authenticator in tests so client.as(principal) works. Requests without a test token go to fallback, so you can wrap your real authenticator and test real tokens alongside.

final auth = TestAuthenticator(
fallback: JwtAuthenticator(key: JwtKey.hmac(testSecret), algorithms: {'HS256'}),
);

See also: Test controllers guide.