Skip to main content
Version: Next

Testing

The most faithful test of a Relic app sends it real HTTP requests. Serve the app on a free port, point an HTTP client at it, and close it when the test ends. That covers everything a client sees, from routing to the headers the adapter writes.

When a test needs a Request object of its own, for example to call a middleware directly, there is a second way. It runs the handler in a RelicServer on an in-process adapter, with no sockets at all.

Testing against a running server​

Serve the app on port 0 and the operating system picks a free port. The port of the returned server says which one it got. The test below starts a fresh server for each test in setUp and closes it with addTearDown.

Testing a served app
import 'package:http/http.dart' as http;
import 'package:relic/relic.dart';
import 'package:test/test.dart';

RelicApp buildApp() =>
RelicApp()
..get('/hello', (req) => Response.ok(body: Body.fromString('Hello!')));

void main() {
group('Given the app served on a free port', () {
late Uri baseUrl;

setUp(() async {
final app = buildApp();
final server = await app.serve(port: 0);
addTearDown(app.close);
baseUrl = Uri.http('127.0.0.1:${server.port}');
});

test('when a client sends GET /hello, '
'then it answers 200 with a greeting', () async {
final response = await http.get(baseUrl.resolve('/hello'));

expect(response.statusCode, 200);
expect(response.body, 'Hello!');
});

test('when a client sends GET to a path with no route, '
'then it answers 404', () async {
final response = await http.get(baseUrl.resolve('/missing'));

expect(response.statusCode, 404);
});
});
}

This needs package:http as a dev dependency. serve binds to the IPv4 loopback address by default, so the test connects to 127.0.0.1. The name localhost can resolve to the IPv6 address first.

Testing without sockets​

Request has no public constructor. A server builds every request from what its adapter received, through a RequestFactory that only the server holds. A test that needs a Request without opening a socket therefore runs the handler in a RelicServer on an adapter of its own.

The file below is such an adapter. Copy it into your test directory. It needs nothing beyond package:relic and package:test.

  • handle runs a handler in a server and answers one request with it.
  • requestFor returns the Request the server built for a request, for code that takes a Request directly.
An in-process adapter
import 'dart:async';

import 'package:relic/relic.dart';
import 'package:test/fake.dart';

/// An adapter with no sockets. [handle] pushes each request straight into
/// the server.
final class InProcessAdapter extends Fake implements Adapter {
late ExchangeSink _sink;


AdapterCapabilities get capabilities => const AdapterCapabilities();


List<Listener> get listeners => const [];


void start(ExchangeSink sink) => _sink = sink;


Future<void> close({bool force = false}) async {}
}

/// One request, and the response the server answers it with.
final class InProcessExchange extends Fake implements AdapterExchange {
final Method method;
final Uri url;
final Headers headers;
final Body body;

final _response = Completer<Response>();
final _done = Completer<ExchangeEnd>();
final _cancelled = Completer<void>();

InProcessExchange(this.method, this.url, {Headers? headers, Body? body})
: headers = headers ?? Headers.empty(),
body = body ?? Body.empty();

/// The response the server sent.
Future<Response> get response => _response.future;


HttpProtocol get protocol => HttpProtocol.http11;


Request toRequest(RequestFactory create) =>
create(method, this, url: url, headers: headers, body: body);


FutureOr<void> respond(Response response) {
_response.complete(response);
_done.complete(ExchangeEnd.completed);
}


void abort() {
_response.completeError(StateError('The server aborted the exchange'));
_done.complete(ExchangeEnd.aborted);
}


Future<void> get cancelled => _cancelled.future;


Future<ExchangeEnd> get done => _done.future;
}

/// Runs [handler] in a [RelicServer] and answers one request with it.
Future<Response> handle(
Handler handler,
Method method,
Uri url, {
Headers? headers,
Body? body,
}) async {
final adapter = InProcessAdapter();
final server = RelicServer(() => adapter);
await server.mountAndStart(handler);
try {
final exchange = InProcessExchange(
method,
url,
headers: headers,
body: body,
);
await adapter._sink(exchange);
return await exchange.response;
} finally {
await server.close();
}
}

/// The [Request] the server builds for a request, for code that takes a
/// [Request] directly.
Future<Request> requestFor(Method method, Uri url, {Headers? headers}) async {
late Request captured;
await handle(
(req) {
captured = req;
return Response.ok();
},
method,
url,
headers: headers,
);
return captured;
}

InProcessAdapter and InProcessExchange extend Fake from package:test, so they only implement what the server calls for a plain request and response. A handler that hijacks the connection or upgrades to a WebSocket needs a real server.

Here it tests a middleware, first through the server and then directly with a request from requestFor:

Testing a middleware in process
import 'package:relic/relic.dart';
import 'package:test/test.dart';

import 'in_process_server.dart';

/// Answers 401 to a request without the right x-api-key.
Middleware requireApiKey(String key) =>
(next) => (req) {
if (req.headers['x-api-key']?.single != key) {
return Response.unauthorized();
}
return next(req);
};

Response _ok(Request req) => Response.ok();

final _url = Uri.parse('http://localhost/registry');

void main() {
test('Given the middleware, when a request has no API key, '
'then it answers 401', () async {
final response = await handle(
requireApiKey('secret')(_ok),
Method.get,
_url,
);

expect(response.statusCode, 401);
});

test('Given the middleware, when a request has the API key, '
'then the inner handler answers.', () async {
final response = await handle(
requireApiKey('secret')(_ok),
Method.get,
_url,
headers: Headers.build((mh) => mh['x-api-key'] = ['secret']),
);

expect(response.statusCode, 200);
});

test('Given a request built by the server, when the middleware is '
'called with it directly, then it answers 401', () async {
final request = await requestFor(Method.post, _url);

final result = await requireApiKey('secret')(_ok)(request);

expect((result as Response).statusCode, 401);
});
}

requestFor hands back the request after the server has answered it. That suits code that reads the method, URL and headers and returns a Response. copyWith makes variations of it with another URL, headers or body. A different method needs another call to requestFor.