HTTP Testing
Every HTTP test is a combination of:
- A
RequestHandlerimplementation (e.g.ShelfRequestHandler) - A
serverTest()wrapper that builds theTestClient - Fluent assertions on the returned
TestResponse
Below is a representative setup with Shelf; swap in your own handler provider if needed.
Test Anatomy
import 'package:shelf/shelf.dart' as shelf;
import 'package:server_testing/server_testing.dart';
import 'package:server_testing_shelf/server_testing_shelf.dart';
void main() {
final handler = ShelfRequestHandler((shelf.Request req) async {
if (req.method == 'POST') {
final payload = await req.readAsString();
return shelf.Response(201, body: payload);
}
return shelf.Response.ok({'users': []},
headers: {'content-type': 'application/json'});
});
serverTest('GET /users returns 200', (client, _) async {
final res = await client.get('/users');
res.assertStatus(200).assertJson((json) {
json.has('users');
});
}, handler: handler, transportMode: TransportMode.inMemory);
serverTest('POST echoes body', (client, _) async {
final res = await client.postJson('/users', {'id': 1});
res.assertStatus(201).assertJsonPath('id', 1);
}, handler: handler);
}
serverTest ensures the client is closed even when assertions fail. Use setUp/tearDown around the call if you need additional wiring such as database fixtures.
Choosing a Transport
TransportMode.inMemory(default) keeps everything inside a single process for extreme speed. It is ideal for pure business-logic coverage where HTTP byte-level behaviour is not relevant.TransportMode.ephemeralServerspins up a realHttpServerper test. Use it when you rely on socket-level semantics, need a shareable base URL, or want to drive a browser against the same handler.
serverTest('real server mode', (client, _) async {
final res = await client.get('/');
res.assertStatus(200).assertBody('hi');
}, handler: handler, transportMode: TransportMode.ephemeralServer);
The returned TestClient exposes baseUrlFuture in server mode, which resolves to http://127.0.0.1:<port>.
Making Requests
TestClient covers the common verbs (get, post, put, patch, delete, head) plus JSON helpers (getJson, postJson, …) and multipart support:
final response = await client.multipart('/upload', (builder) {
builder.addField('name', 'fixture');
builder.addFileFromString(
name: 'notes',
filename: 'notes.txt',
content: 'hello world',
);
});
response.assertStatus(200);
- Pass headers via the optional
headersparameter (Map<String, List<String>>). - Provide raw bodies as a
String, bytes (List<int>), or even a map/list for JSON helpers (auto-encoded). - Supply
TransportOptionswhen constructing the client if you must tweak networking defaults (e.g.remoteAddress).
Working With Responses
Every method returns a TestResponse:
assertStatus,assertSuccess,assertClientError,assertServerErrorassertBody,assertBodyContains,assertBodyIsEmpty,assertNoBodyassertHasHeader,assertHeader,assertHeaderContains,assertContentType- JSON helpers:
json(path),assertJsonPath,assertJson,assertJsonContains - Debug helpers:
dump()to print an HTTP-formatted response
See the dedicated TestResponse guide for the full API.
Sharing Handlers Across Tests
When a handler is expensive to build, create it in a setUp callback and reuse it:
late ShelfRequestHandler handler;
setUp(() {
handler = ShelfRequestHandler(buildApp());
});
serverTest('works', (client, h) async {
expect(identical(handler, h), isTrue); // same instance you passed in
await client.get('/health');
}, handler: handler);
In server mode you can also reuse the handler with browser automation; see Browser Testing for an end-to-end example.
Multi‑Value Headers & Cookies
TestResponse.assertHeaderContains understands multi-value headers. It searches all values for a match, so these pass:
res.assertHeaderContains(HttpHeaders.setCookieHeader, 'session=');
res.assertHeaderContains('X-Custom-Header', ['Value', 'Value2']);
For Set-Cookie the in‑memory transport now mirrors a real server: each cookie is preserved as a distinct value rather than being comma-joined. Multiple .add() calls to the same non Set-Cookie header are accumulated into the first entry (matching dart:io semantics) and exposed as a single list element when appropriate.
Transport Consistency Guarantees
Both transports enforce:
- Header mutability rules (status/headers cannot change after body write or close)
- Distinct
Set-Cookievalues - HEAD responses omit bodies while sharing status/headers with GET
Use in‑memory for speed and deterministic state isolation; switch to ephemeral when you need actual socket behaviour (e.g. integration with external clients). Your assertions should behave identically.
Route Harness Pattern
For framework‑agnostic handlers (e.g. plain dart:io), build a reusable harness that runs the same request matrix against both transports:
void main() {
Future<void> handle(HttpRequest req) async { /* routes */ await req.response.close(); }
final handler = IoRequestHandler(handle);
for (final mode in TransportMode.values) {
serverTest('GET /json [$mode]', (c, _) async {
final r = await c.get('/json');
r.assertStatus(200).assertHeaderContains('content-type', 'application/json');
}, handler: handler, transportMode: mode);
}
}
Keeping this harness in your project lets you validate any new adapter (Shelf, custom framework, etc.) against the same behavioural contract.