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.
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.
handleruns a handler in a server and answers one request with it.requestForreturns theRequestthe server built for a request, for code that takes aRequestdirectly.
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:
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.