> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/mock-web-server-service-api-test.md).

# Mock Web Server for Service/API Tests

How I use a local HTTP server to verify request/response contracts, timeouts, and service error mapping without calling a real backend

## Result

In my project, mocking a service method directly makes state and UI tests fast. However, a test can still pass when the service creates the wrong HTTP method, omits a query parameter, or sends a body that violates the contract.

I use an HTTP server running on loopback for tests that need to protect the network boundary. The service still creates requests and parses responses with real code; only the backend is replaced by a local server with deterministic responses.

```
Test fixture
    │
    ├── configure response / dispatcher
    │
    ▼
Service under test ── HTTP request ──► Loopback server
    ▲                                      │
    │                                      ├── status · headers · body · delay
    │                                      │
    └──────── parsed result / typed error ◄┘
                                           │
                                           ▼
                                  Stored request assertion
                                  method · path · query
                                  headers · body
```

Mock responses drive success, error, and timeout paths. Stored requests answer the other question: did the service send the correct contract?

I choose a test double according to the boundary I need to protect:

| Test approach                  | Boundary                    | Best fit                                                                   |
| ------------------------------ | --------------------------- | -------------------------------------------------------------------------- |
| Mock repository/service method | Caller ↔ Dart abstraction   | Only state, callbacks, or UI need to be controlled.                        |
| Fake `http.Client`             | Service ↔ client interface  | The request object must be inspected without opening a socket.             |
| Mock Web Server                | Service/app ↔ loopback HTTP | Real URL serialization, status, headers, body, and delay must be verified. |

The source I reviewed has two mock-server wrappers and two BDD lifecycle hooks. However, the standard CI job does not currently call those device-integration targets. I treat the harness as an existing capability, not as evidence that service/API tests are already an automatic CI gate.

## Problem

### Method mocks can miss the HTTP contract

Suppose a caller receives a profile from a service:

```dart
when(() => repository.loadProfile()).thenAnswer(
  (_) async => const Profile(id: 'demo-user'),
);
```

This test does not detect failures below the abstraction:

* The service uses `POST` instead of `GET`.
* The path is missing a version or uses the wrong resource.
* A query parameter is omitted.
* The `Accept` header or authorization policy is wrong.
* The JSON body uses the wrong type or field.
* A `401` response is parsed as success.
* The client does not apply the expected timeout.

Method mocks are still appropriate for widget or state tests. I move down to a Mock Web Server only when the HTTP request/response itself is the contract that needs protection.

### A correct response is not enough

A test that only asserts the returned model may prove that the parser can read the fixture:

```dart
final profile = await api.loadProfile();

expect(profile.id, 'demo-user');
```

If the mock server returns the same JSON for every request, the test still passes when the URL is wrong. I therefore verify both directions:

```
Response assertion
status · parsed model · typed error

Request assertion
method · path · query · headers · body · request count
```

Both assertion layers use the same call. I do not invoke the service a second time only to read the request record.

### FIFO queues break down when requests are no longer sequential

A response queue is easy to use:

```
request 1 ─► response 1
request 2 ─► response 2
request 3 ─► response 3
```

But when two requests run concurrently, responses may be consumed in the order requests reach the server, not in the business-flow order the test assumes. A test that passes locally can fail on a slower runner.

I use a queue for a linear sequence and a dispatcher for multiple routes or concurrency:

```
GET  /v1/profile ─► profile response
GET  /v1/settings ─► settings response
POST /v1/session ─► session response
unknown route    ─► 404
```

The `404` fallback is important. A default `200 {}` for every request can hide a missing stub and let the app continue with fake data that violates the contract.

### Ports and lifecycle are a major source of flakiness

A mock server is still an `HttpServer`. It must bind a port, receive requests, and close its listener.

Common failures include:

* The test sends a request before the server finishes starting.
* Two tests or CI workers compete for the same fixed port.
* App configuration still points to the real backend.
* A stub or controller from the previous case remains active.
* The server is not shut down when an assertion fails.
* A helper switches to another port while the app still calls the old one.

For service tests running on the host, I use port `0` so the operating system chooses an ephemeral port. A fixed port is appropriate only when a binary or device-test configuration must know the URL in advance.

### The source has multiple layers, but automation is not unified

The source I reviewed contains:

* A small wrapper in the service package.
* A wrapper under `test_driver/` with default stubs, controllers, and scenario reset.
* A lifecycle hook for the legacy Flutter Driver path.
* A lifecycle hook for the newer `integration_test` path.
* A package-test helper that can start the server on a dynamic port.

However, the dynamic-port helper currently has no consumer in the source. Standard CI runs widget tests and root/package tests, but it does not call the two BDD/device-integration targets that use the mock server.

I keep three states separate:

| State           | Example in the source                                           |
| --------------- | --------------------------------------------------------------- |
| Exists          | Wrappers, dispatcher, start/reset/stop hooks, and Make targets. |
| Has no consumer | Setup helper in the package test directory.                     |
| Not implemented | Dedicated service-test target and a CI gate for that target.    |

## Solution

### Choose the right test boundary

I use this rule before opening a local server:

```
Only need fake state for UI?             ─► mock repository/service method
Need to assert Request/Response objects? ─► fake HTTP client
Need serialization through real HTTP?    ─► loopback Mock Web Server
Need an app/device/native flow?           ─► integration/BDD test
```

A Mock Web Server adds socket, routing, and cleanup costs. I do not pull it into a widget test merely to return an already parsed object.

### Start the server on an ephemeral port

With the package in this article, I create the server on port `0` and wait for `start()` to complete:

```dart
import 'package:mock_web_server/mock_web_server.dart';
import 'package:test/test.dart';

late MockWebServer server;

void main() {
  setUp(() async {
    server = MockWebServer(port: 0);
    await server.start();
  });

  tearDown(() {
    server.shutdown();
  });
}
```

Dart's `HttpServer.bind` chooses an ephemeral port when given port `0`. After startup, the package exposes `server.host`, `server.port`, and `server.url`.

I do not catch every exception and automatically try `1024`, `1025`, then `1026`. A permission, certificate, or lifecycle failure should not turn into an unbounded “try the next port” loop.

The locked fork has one limitation: `shutdown()` calls `HttpServer.close()` but does not return that `Future`. The code above therefore cannot await listener closure. If the team owns the fork, I change the API to return the close future before relying on the port being fully released:

```dart
Future<void> shutdown({bool force = false}) async {
  await _server.close(force: force);
}
```

This is a proposed improvement, not the API of the currently reviewed source version.

### Inject the base URL into the service

I pass the server URL through the constructor instead of changing a production endpoint:

```dart
final api = ProfileApi(
  baseUri: Uri.parse(server.url),
);
```

A minimal service can resolve paths from that base URI:

```dart
class ProfileApi {
  ProfileApi({
    required this.baseUri,
    required this.client,
  });

  final Uri baseUri;
  final Client client;

  Future<Profile> loadProfile({bool expandSettings = false}) async {
    final uri = baseUri.resolve('v1/profile').replace(
      queryParameters: {
        if (expandSettings) 'expand': 'settings',
      },
    );

    final response = await client.get(
      uri,
      headers: {'Accept': 'application/json'},
    );

    if (response.statusCode >= 400) {
      throw ApiFailure.fromResponse(response);
    }

    return Profile.fromJson(jsonDecode(response.body));
  }
}
```

`Profile`, `ApiFailure`, and the payload in this example are synthetic. The important part is that the base URI and client are injected, so the test does not need to touch global production configuration.

If existing code reads only a singleton configuration, I override it in `setUp` and restore it in `tearDown`. Constructor injection still creates a smaller and more isolated scope.

### Use a response queue for simple sequences

A response can contain a status, headers, a body, and a delay:

```dart
server.enqueue(
  httpCode: 200,
  headers: {'content-type': 'application/json'},
  body: '{"id":"demo-user","enabled":true}',
);
```

The test then runs the service and reads the stored request:

```dart
final profile = await api.loadProfile(
  expandSettings: true,
);

expect(profile.id, 'demo-user');

final request = server.takeRequest();
expect(request.method, 'GET');
expect(request.uri.path, '/v1/profile');
expect(request.uri.queryParameters['expand'], 'settings');
expect(request.headers['accept'], contains('application/json'));
```

The package stores requests in FIFO order. `takeRequest()` returns the first request and removes it from the queue. The method throws if no request is available, so I call it only after awaiting the service call.

### Use a dispatcher for multiple routes

When a test has multiple or concurrent requests, I route by method and path:

```dart
server.dispatcher = (request) async {
  if (request.method == 'GET' &&
      request.uri.path == '/v1/profile') {
    return MockResponse()
      ..httpCode = 200
      ..headers = {'content-type': 'application/json'}
      ..body = '{"id":"demo-user","enabled":true}';
  }

  if (request.method == 'GET' &&
      request.uri.path == '/v1/settings') {
    return MockResponse()
      ..httpCode = 200
      ..headers = {'content-type': 'application/json'}
      ..body = '{"notifications":false}';
  }

  return MockResponse()
    ..httpCode = 404
    ..headers = {'content-type': 'application/json'}
    ..body = '{"message":"Not Found"}';
};
```

When a dispatcher is set, the package does not consume responses from the FIFO queue. I choose one policy per test instead of configuring both a queue and a dispatcher and relying on implicit order.

### Normalize query parameters

These two URLs mean the same thing when the API does not treat query ordering as part of the contract:

```
/v1/items?page=2&sort=name
/v1/items?sort=name&page=2
```

The source wrapper currently sorts query keys before building a route key. I keep that behavior in the shared helper.

For repeated query keys:

```
/v1/items?tag=a&tag=b
```

A single-value `queryParameters` map is not enough. I use `queryParametersAll` or a dedicated matcher so values are not discarded.

Headers and bodies should not be packed into a long route-key string. I match routes by method/path/query, then assert headers and bodies from the stored request.

### Assert JSON bodies structurally

For POST or PATCH, I do not compare raw JSON strings when key order is not part of the contract:

```dart
final request = server.takeRequest();
final body = jsonDecode(request.body ?? '') as Map<String, dynamic>;

expect(request.method, 'POST');
expect(request.uri.path, '/v1/session');
expect(body, {
  'deviceId': 'demo-device',
  'remember': false,
});
```

I do not print a complete Authorization header or sensitive body when an assertion fails. Public fixtures use only synthetic IDs and contain no real token.

### Test a complete response matrix

An important service needs more than a `200` case:

| Case           | Mock response                                    | Main assertion                                |
| -------------- | ------------------------------------------------ | --------------------------------------------- |
| Success        | `200` + valid JSON                               | Model and outbound request are correct.       |
| Empty success  | `204` or an empty body according to the contract | JSON is not parsed blindly.                   |
| Client error   | `400`/`401`/`404` + error JSON                   | Typed error retains the required status/code. |
| Server error   | `500`                                            | No success model is returned.                 |
| Malformed JSON | `200` + invalid schema                           | Parser fails clearly.                         |
| Delay          | Delay exceeds client timeout                     | Timeout/cancel path is mapped correctly.      |
| Missing stub   | `404` fallback                                   | Missing route becomes visible.                |
| Retry          | Failure followed by success                      | Request count and retry policy are correct.   |

Delay is an input for entering a timeout or race path, not a performance benchmark.

Timeout example:

```dart
server.enqueue(
  httpCode: 200,
  delay: const Duration(milliseconds: 200),
  body: '{"id":"demo-user"}',
);

await expectLater(
  api.loadProfile().timeout(
    const Duration(milliseconds: 50),
  ),
  throwsA(isA<TimeoutException>()),
);
```

The durations in this example only illustrate the relationship between response delay and client timeout. They are not shared thresholds for every project.

### Reset state at the correct scope

For a small service test, I create one server per test. Its queue and request records naturally have a short scope.

For a BDD run that shares one server:

```
onBeforeRun      start
onBeforeScenario reset default fixtures
scenario         override only what it owns
onAfterRun       shutdown
```

`reset()` must restore default fixtures when the app makes bootstrap requests before the first step. A plain `clear()` can make the scenario fail before reaching the action under test.

When I suspect an order dependency, I run the scenario alone, reverse the suite order, and inspect which stub or controller remains.

### Keep fixed ports within device integration

A fixed port fits an app binary built with a fixed test URL. In return, I keep these contracts:

* Server port and app configuration always match.
* A bind failure fails the test clearly.
* Parallel jobs do not share the same port or runner namespace.
* An old process is cleaned up before startup.
* The server does not silently switch ports when the app cannot receive a new URL.

A host-side service test has no such constraint, so an ephemeral port remains my default.

### Keep dependencies in the correct scope

The current source places the mock package under production `dependencies` in multiple manifests, while one wrapper lives under `lib/`. Moving only the dependency to `dev_dependencies` would break consumers of that helper.

When the mock server is test-only, I refactor in one of two directions:

```
One package uses it
└── packages/services/test/support/mock_http_server.dart

Multiple packages use it
└── packages/test_support/lib/mock_http_server.dart
```

Only after consumers have moved do I change the dependency scope and run workspace-wide analysis and tests. This is a structural proposal, not an implemented change in the source I reviewed.

### Create a CI target with evidence

A dedicated target lets local development and CI use the same command:

```makefile
.PHONY: service-test
service-test:
	fvm flutter test packages/services/test --reporter compact
```

The pipeline could then call:

```yaml
script:
  - make service-test
```

These two snippets are proposed configuration. The standard CI source does not currently contain that target or service-test gate.

I describe automation as “existing” only after verifying all of the following:

1. The pipeline actually calls the target.
2. A failed assertion makes the job return non-zero.
3. Logs do not expose sensitive headers or payloads.
4. Tests do not call a production domain.
5. Parallel runs do not compete for a fixed port.

### Common failures

| Symptom                                     | Common cause                                                             | How to diagnose and fix it                                                            |
| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `Address already in use`                    | Another process or job holds the fixed port.                             | Use port `0` for host tests; for device tests, clean up the process and fail clearly. |
| App still calls the real backend            | Base URL was not injected before initialization.                         | Assert the test base URL and block production domains in tests.                       |
| Request receives `404`                      | Method/path/query does not match the stub.                               | Read the stored request and log a redacted route key.                                 |
| Test passes although the body is wrong      | Only the response is asserted.                                           | Parse `takeRequest().body` and compare the contract.                                  |
| Passes alone, fails in the suite            | Global stubs or controllers were not reset.                              | Reset before each test/scenario or create a new server.                               |
| A response is assigned to the wrong request | FIFO queue is used for concurrent requests.                              | Switch to a route-based dispatcher.                                                   |
| `takeRequest` throws                        | The request has not completed or never reached the server.               | Await the service call and verify the base URL.                                       |
| Test times out                              | Server did not start, delay is too long, or the app calls another route. | Await `start`, use a `404` fallback, and set a deliberate timeout.                    |
| Server changes port but app still fails     | Port fallback does not update app configuration.                         | Return the actual URL to the caller or fail the fixed-port bind.                      |
| Fresh dependency resolution fails           | Git access or an old fork's SDK constraint.                              | Verify source pinning, SDK constraints, and clean-cache resolution.                   |

### Trade-offs

* A loopback server protects the HTTP contract better than a method mock but adds socket and lifecycle costs.
* An ephemeral port reduces collisions but does not fit an app that knows only a fixed URL.
* A dispatcher makes routes explicit but can become an oversized fake backend when every fixture enters one global file.
* Default fixtures help app bootstrap but can hide requests outside a scenario's scope.
* Request recording improves assertions but failure logs can expose tokens or sensitive data.
* Delay reproduces timeout and race paths but slows the suite when durations are too long.
* Sharing one server across a BDD run lowers startup cost but requires correct resets.
* Local HTTP does not prove production DNS, TLS, certificate pinning, proxies, VPNs, or real device networking.

### Verify the result

A complete service/API contract test should pass this matrix:

| Case               | Server setup                 | Request assertion              | Expected result                                  |
| ------------------ | ---------------------------- | ------------------------------ | ------------------------------------------------ |
| GET success        | `200` route + synthetic JSON | Correct method/path/query      | Model parses correctly.                          |
| POST success       | `201` route                  | Correct headers and JSON body  | Success result.                                  |
| Typed `401`        | `401` route + error fixture  | Auth header follows policy     | Error keeps status/code; no success is returned. |
| Malformed response | `200` + invalid JSON schema  | Request remains correct        | Parser fails clearly.                            |
| Timeout            | Delay > client timeout       | Request count follows policy   | Timeout/cancel is mapped correctly.              |
| Unknown route      | No stub or controller        | Stored request shows the route | `404`, no success fallback.                      |
| Scenario isolation | Override, then reset         | Call the same route again      | Default response is restored.                    |
| Parallel routes    | Path-based dispatcher        | Each request matches its route | No dependency on enqueue order.                  |
| Port collision     | Fixed port is already held   | No request                     | Startup fails clearly without an unbounded loop. |

My research run on 2026-09-02 verified only source and configuration; it did not execute a valid focused service test on the current toolchain.

The evidence is separated explicitly:

* Dependency, package source, queue/dispatcher, and request records: verified.
* Two wrappers and two BDD lifecycle hooks: verified.
* Consumer for the package-test helper: not found in the source snapshot.
* Standard CI calling integration/service tests: not found.
* Focused runtime pass and fixed-port parallel behavior: not confirmed.
* Fresh resolution of the private fork on Dart 3.11: not confirmed.

I do not use source tests inside the dependency checkout to claim that the app suite passes. Runtime evidence is valid only when workspace dependencies resolve successfully and the assertion actually runs.

### Related articles

A Mock Web Server sits behind the HTTP client boundary. The next article owns auth snapshots, typed errors, and production request policy; this article only verifies that contract through a loopback server.

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

When a test needs to run the whole app across multiple screens, lifecycle hooks, and device dependencies, I move up to integration/BDD.

{% content-ref url="/pages/5gKOCHnCinMIWgGm3uxB" %}
[BDD with Flutter Gherkin](/flutter/my-flutter/quality-delivery/bdd-flutter-gherkin.md)
{% endcontent-ref %}

A service-test target becomes a quality gate only after the pipeline actually calls it and preserves the correct exit status.

{% content-ref url="/pages/iXaQgqQ1eqF71OtJywau" %}
[GitLab CI for a Flutter Monorepo](/flutter/my-flutter/quality-delivery/gitlab-ci-flutter-monorepo.md)
{% endcontent-ref %}

A loopback contract test does not replace runtime classification of offline, VPN, or physical-network failures.

{% content-ref url="/pages/kThPOkuxgcDBnbaVwp31" %}
[Flutter Connectivity and Network Failures](/flutter/my-flutter/systems-realtime/connectivity-offline-ux-vpn.md)
{% endcontent-ref %}

### Versions reviewed

* Flutter workspace: `3.41.2`.
* App Dart SDK constraint: `>=3.11.0 <4.0.0`.
* `mock_web_server`: `5.0.0-nullsafety.1` from the locked Git dependency.
* Platform scope: Shared for host-side service tests; device integration requires separate configuration.

### References

* [Dart API — `HttpServer.bind`](https://api.dart.dev/dart-io/HttpServer/bind.html)
* [Dart API — `HttpServer.close`](https://api.dart.dev/dart-io/HttpServer/close.html)
* [Dart API — `HttpRequest`](https://api.dart.dev/dart-io/HttpRequest-class.html)
* [`package:test` — `setUp`](https://pub.dev/documentation/test/latest/test/setUp.html)
* [`package:test` — `tearDown`](https://pub.dev/documentation/test/latest/test/tearDown.html)
* [`package:http` — `Client`](https://pub.dev/documentation/http/latest/http/Client-class.html)
* [MockWebServer public mirror](https://github.com/haarts/MockWebServer)

## Conclusion

A Mock Web Server is valuable when I need to protect the HTTP contract, not only the state after mocking has already happened. Response fixtures drive success, error, and timeout paths. Stored requests prove that the service actually sent the correct method, URL, query, headers, and body.

Reliability comes from lifecycle and isolation: wait for server startup, inject the correct base URL, use an ephemeral port when possible, fall back to `404`, reset state, and shut down after the test. A queue fits a simple sequence; a dispatcher fits multiple routes or concurrency.

I do not call a harness a CI gate merely because wrappers and Make targets exist. The pipeline must actually run focused service tests, preserve a non-zero exit status, and avoid calling production backends. For app/device flows or physical-network failures, I move to integration tests and runtime network checks instead of stretching the Mock Web Server beyond its scope.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
