Skip to main content

HTTP Testing

Every HTTP test is a combination of:

  1. A RequestHandler implementation (e.g. ShelfRequestHandler)
  2. A serverTest() wrapper that builds the TestClient
  3. 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.ephemeralServer spins up a real HttpServer per 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 headers parameter (Map<String, List<String>>).
  • Provide raw bodies as a String, bytes (List<int>), or even a map/list for JSON helpers (auto-encoded).
  • Supply TransportOptions when constructing the client if you must tweak networking defaults (e.g. remoteAddress).

Working With Responses

Every method returns a TestResponse:

  • assertStatus, assertSuccess, assertClientError, assertServerError
  • assertBody, assertBodyContains, assertBodyIsEmpty, assertNoBody
  • assertHasHeader, 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-Cookie values
  • 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.