Skip to content

Test controllers

You’ll write fast tests that send requests through the full gisila pipeline (routing, binding, forms, guards, error mapping) without starting a server, authenticate as different users, and unit-test a service on its own.

  • test in dev_dependencies and generated .g.dart files (dart run build_runner build).

Share the registration code between main() and tests so they can’t drift:

lib/app.dart
import 'package:gisila/gisila.dart';
void configureApp(GisilaApp app) {
app.registerSingleton<NoteRepository>(NoteRepository());
app.registerService<NoteService>(NoteService.new);
app.registerController(attacher: NotesController().attachToApp);
}
test/notes_test.dart
import 'package:gisila/gisila.dart';
import 'package:gisila/testing.dart';
import 'package:notes_api/app.dart';
import 'package:test/test.dart';
void main() {
late GisilaApp app;
late GisilaTestClient client;
setUp(() async {
app = GisilaApp(
config: AppConfig(authenticator: TestAuthenticator(), logRequests: false),
);
configureApp(app);
await app.init(); // factory singletons, onStart hooks, cache
client = GisilaTestClient(app);
});
tearDown(() => app.stop());
test('creates and reads a note', () async {
final created = await client.post('/notes', json: {'title': 'Hi'});
expect(created.statusCode, 201);
final id = created.jsonMap['id'];
final read = await client.get('/notes/$id');
expect(read.statusCode, 200);
expect(read.jsonMap['title'], 'Hi');
});
}

GisilaTestClient(app) builds the handler once, so register everything before creating it. It doesn’t call init(); do that yourself when you use factory singletons, onStart hooks or a cache. logRequests: false keeps test output quiet.

With TestAuthenticator as the app’s authenticator, client.as(principal) returns a client that is authenticated as that principal. Assuming the delete route carries @Permissions({'notes:write'}):

test('only writers may delete', () async {
final reader = client.as(const Principal(id: 'r', permissions: {'notes:read'}));
final writer = client.as(const Principal(id: 'w', permissions: {'notes:write'}));
expect((await client.delete('/notes/1')).statusCode, 401);
expect((await reader.delete('/notes/1')).errorCode, 'forbidden');
expect((await writer.delete('/notes/1')).statusCode, anyOf(204, 404));
});

Clients are immutable: as, withToken and withHeaders return new clients.

To test real tokens too, wrap your production authenticator: TestAuthenticator(fallback: jwtAuthenticator), then use client.withToken(signer.sign(subject: 'u1')).

final res = await client.post('/notes', json: {});
expect(res.statusCode, 400);
expect(res.errorCode, 'validation_failed');
expect(res.error!['details'], containsPair('title', 'This field is required'));

TestResponse gives you statusCode, case-insensitive headers, body, json, jsonMap, jsonList, error, errorCode and errorMessage.

5. Send forms, raw bodies and query parameters

Section titled “5. Send forms, raw bodies and query parameters”
await client.post('/login', form: {'email': 'a@b.co', 'password': 'pw'}); // url-encoded
await client.put('/blobs/1', body: [1, 2, 3], contentType: 'application/octet-stream');
await client.get('/notes', query: {'limit': 10, 'tag': ['a', 'b']}); // ?limit=10&tag=a&tag=b

A service that takes its dependencies through its constructor is the easiest to unit-test. Register it with a closure in the app, construct it directly in tests, and attach a hand-made context when it reads ctx:

class NoteService extends Service {
NoteService(this._repo);
final NoteRepository _repo;
Note get(int id) => _repo.find(id) ?? (throw NotFound('Note $id not found'));
String get caller => ctx.principal?.id ?? 'anonymous';
}
// In the app: app.registerService<NoteService>(() => NoteService(app.singleton<NoteRepository>()));
test('get throws NotFound for unknown ids', () {
final service = NoteService(NoteRepository())
..attach(RequestContext(
requestId: 'test',
request: Request('GET', Uri.parse('http://localhost/notes/42')),
principal: const Principal(id: 'u1'),
));
expect(() => service.get(42), throwsA(isA<NotFound>()));
expect(service.caller, 'u1');
});

A hand-made context has no service or singleton resolver, so ctx.service<T>() and singleton<T>() throw StateError there. Test code that relies on them through GisilaTestClient.

Terminal window
dart test