deceiver

Configure and provide fake HTTP responses for Dart's package:http Client. Intercept requests with URI templates, toggle mocks at runtime, and passthrough unmatched calls to the real HTTP client.

Inspired by Charlatan (for Dio), Deceiver brings the same ergonomic fake-response pattern to package:http.

Features

  • Register fake responses by HTTP method and URI template path
  • Response helpers for JSON, errors, and empty responses
  • Sequential responses -- cycle through different responses on each call
  • Random responses -- pick a response at random from a list
  • Named scenarios -- define preset mock configurations and switch at runtime
  • Passthrough unmatched requests to the real HTTP client
  • Toggle all mocks on/off at runtime with a global switch
  • Toggle individual mocks on/off without removing them
  • Custom request matchers for advanced matching logic
  • Async response builders for simulating delays or conditional logic
  • Descriptive error messages when strict mode is enabled
  • Drop-in replacement for package:http Client

Installation

Add deceiver to your pubspec.yaml as a dev dependency:

dev_dependencies:
  deceiver: ^1.0.0

Then run:

dart pub get

Quick Start

import 'package:deceiver/deceiver.dart';

void main() async {
  final deceiver = Deceiver();

  // Register mock responses
  deceiver.on.get('/users', jsonResponse(body: [{'name': 'Bilbo'}]));
  deceiver.on.post('/users', jsonResponse(body: {'id': 1}, statusCode: 201));
  deceiver.on.delete('/users/{id}', emptyResponse());

  // Create a client and make requests
  final client = deceiver.client();

  final users = await client.get(Uri.parse('https://api.example.com/users'));
  print(users.body); // [{"name":"Bilbo"}]

  final created = await client.post(Uri.parse('https://api.example.com/users'));
  print(created.statusCode); // 201

  final deleted = await client.delete(Uri.parse('https://api.example.com/users/42'));
  print(deleted.statusCode); // 204

  client.close();
}

Usage

Registering fake responses

Use deceiver.on.<method>(path, responseBuilder) to register mocks for each HTTP method:

final deceiver = Deceiver();

deceiver.on.get('/users', (req) => Response('[]', 200));
deceiver.on.post('/users', (req) => Response('{"id": 1}', 201));
deceiver.on.put('/users/{id}', (req) => Response('{"name": "updated"}', 200));
deceiver.on.delete('/users/{id}', (req) => Response('', 204));
deceiver.on.patch('/users/{id}', (req) => Response('{"name": "patched"}', 200));
deceiver.on.head('/users', (req) => Response('', 200));

Paths use RFC 6570 URI templates, so {id} will match any value in that path segment. A template like /users/{id} will match /users/42 but not /users/42/posts.

Response helpers

For common response patterns, use the built-in helpers instead of constructing Response objects manually:

// JSON response with automatic content-type header and encoding
deceiver.on.get('/users', jsonResponse(body: [{'name': 'Bilbo'}]));
deceiver.on.post('/users', jsonResponse(body: {'id': 1}, statusCode: 201));

// Error response with optional JSON message body
deceiver.on.get('/secret', errorResponse(403, message: 'Forbidden'));
deceiver.on.get('/missing', errorResponse(404));

// Empty response (defaults to 204 No Content)
deceiver.on.delete('/users/{id}', emptyResponse());
deceiver.on.post('/reset', emptyResponse(statusCode: 202));

All helpers accept an optional headers parameter for custom headers:

deceiver.on.get('/users', jsonResponse(
  body: [],
  headers: {'x-request-id': 'abc-123'},
));

Dynamic responses

Response builders receive the full Request object, so you can build responses based on the request body, headers, URL, or query parameters:

deceiver.on.post('/users', (req) {
  final body = req.body;
  if (body.isEmpty) {
    return Response('{"error": "name is required"}', 422);
  }
  return Response('{"id": 1, "name": "Bilbo"}', 201);
});

Custom matchers

When the built-in method + path matching is not enough, use on.match() with an arbitrary predicate:

deceiver.on.match(
  (req) => req.method == 'GET' && req.url.queryParameters['q'] == 'dart',
  (req) => Response('{"results": ["dart"]}', 200),
  description: 'GET search?q=dart',
);

The description parameter is used in debug output and error messages.

Async response builders

Response builders support FutureOr<Response>, so you can simulate network delays or perform async work:

deceiver.on.get('/slow', (req) async {
  await Future.delayed(Duration(milliseconds: 500));
  return Response('{"status": "done"}', 200);
});

Sequential responses

Return different responses on each successive call to the same endpoint. After the last response, it cycles back to the first:

deceiver.on.get('/status', sequentialResponse([
  jsonResponse(body: {'status': 'loading'}),  // 1st call
  jsonResponse(body: {'status': 'ready'}),    // 2nd call
  errorResponse(500, message: 'Crash'),       // 3rd call
  // 4th call returns 'loading' again, and so on
]));

Random responses

Pick a random response from a list on each call. Useful for simulating flaky services:

deceiver.on.get('/flaky', randomResponse([
  jsonResponse(body: {'status': 'ok'}),
  errorResponse(503, message: 'Try again'),
  errorResponse(429, message: 'Rate limited'),
]));

For deterministic testing, pass a seeded Random:

import 'dart:math';

deceiver.on.get('/det', randomResponse(
  [jsonResponse(body: 'a'), jsonResponse(body: 'b')],
  random: Random(42),
));

Named scenarios

Define preset mock configurations and switch between them at runtime. Scenario mocks layer on top of base mocks -- they override matching endpoints while unmatched endpoints fall through to the base mocks:

// Base mocks (always active)
deceiver.on.get('/users', jsonResponse(body: [{'name': 'Bilbo'}]));
deceiver.on.get('/posts', jsonResponse(body: []));

// Define scenarios
deceiver.scenario('happy', (on) {
  on.get('/users', jsonResponse(body: [
    {'name': 'Bilbo'},
    {'name': 'Frodo'},
    {'name': 'Samwise'},
  ]));
});

deceiver.scenario('error', (on) {
  on.get('/users', errorResponse(500, message: 'Server error'));
});

// Activate a scenario -- overrides base /users mock, /posts still uses base
deceiver.activateScenario('happy');

// Switch scenarios
deceiver.activateScenario('error');

// Deactivate -- back to base mocks only
deceiver.deactivateScenario();

// List available scenarios
print(deceiver.scenarios); // ['happy', 'error']
print(deceiver.activeScenario); // null or 'happy' or 'error'

Passthrough for unmatched requests

By default, requests that don't match any registered mock are forwarded to the real HTTP client:

final deceiver = Deceiver(); // onlyAllowMocks defaults to false
deceiver.on.get('/mocked', jsonResponse(body: {'mocked': true}));

final client = deceiver.client();

// This returns the mock response
await client.get(Uri.parse('https://api.example.com/mocked'));

// This goes to the real server
await client.get(Uri.parse('https://api.example.com/real-endpoint'));

You can also provide your own Client as the underlying real client:

final realClient = MyCustomHttpClient();
final client = deceiver.client(realClient);

Strict mode (only allow mocks)

Set onlyAllowMocks: true to throw a StateError when a request doesn't match any mock. This is useful in tests to catch unexpected HTTP calls:

final deceiver = Deceiver(
  options: DeceiverOptions(onlyAllowMocks: true),
);
deceiver.on.get('/users', jsonResponse(body: []));

final client = deceiver.client();

// This works fine
await client.get(Uri.parse('https://api.example.com/users'));

// This throws a StateError with a helpful message:
//
//   Deceiver: no matching mock found for:
//
//     GET https://api.example.com/unknown
//
//   Registered mocks:
//     GET /users (enabled)
//
await client.get(Uri.parse('https://api.example.com/unknown'));

Runtime enable/disable

Global toggle

Disable all mocks at once. When disabled, every request goes to the real HTTP client:

final deceiver = Deceiver();
deceiver.on.get('/users', jsonResponse(body: []));

final client = deceiver.client();

// Mocked response
await client.get(Uri.parse('https://api.example.com/users'));

// Disable all mocks -- requests go to the real client
deceiver.enabled = false;
await client.get(Uri.parse('https://api.example.com/users')); // real request

// Re-enable mocks
deceiver.enabled = true;
await client.get(Uri.parse('https://api.example.com/users')); // mocked again

Per-mock toggle

Disable individual mocks without removing them:

final deceiver = Deceiver();
deceiver.on.get('/users', jsonResponse(body: []));
deceiver.on.get('/posts', jsonResponse(body: []));

// Disable just the /users mock
deceiver.registrations.first.isEnabled = false;

final client = deceiver.client();

// /users goes to real client, /posts still returns the mock
await client.get(Uri.parse('https://api.example.com/users')); // real
await client.get(Uri.parse('https://api.example.com/posts')); // mocked

Debugging

List all registered mocks and their status:

final deceiver = Deceiver();
deceiver.on.get('/users', jsonResponse(body: []));
deceiver.on.post('/users', jsonResponse(body: {}, statusCode: 201));
deceiver.on.delete('/users/{id}', emptyResponse());

print(deceiver.toPrettyPrintedString());
// Output:
//   GET /users (enabled)
//   POST /users (enabled)
//   DELETE /users/{id} (enabled)

Access the registrations list programmatically:

for (final reg in deceiver.registrations) {
  print('${reg.description} - enabled: ${reg.isEnabled}');
}

API Reference

Class / Function Description
Deceiver Core orchestrator. Manages mock registrations and creates clients.
DeceiverOptions Configuration: onlyAllowMocks controls strict mode.
DeceiverBuilder Fluent API accessed via deceiver.on for registering mocks.
DeceiverData A single mock registration with matcher, builder, and isEnabled flag.
DeceiverResponseBuilder Typedef: FutureOr<Response> Function(Request)
DeceiverRequestMatcher Typedef: bool Function(Request)
jsonResponse() Helper: builds a JSON response with content-type: application/json.
errorResponse() Helper: builds an error response with optional JSON message body.
emptyResponse() Helper: builds an empty response (defaults to 204).
sequentialResponse() Helper: cycles through a list of response builders on each call.
randomResponse() Helper: picks a random response builder from a list on each call.
matchAll() Composes multiple matchers with AND logic.
matchMethod() Matches requests by HTTP method.
matchPath() Matches requests by URI template path.

Contributing

Contributions are welcome. Please open an issue first to discuss the change you'd like to make.

Before submitting a PR:

  1. Run dart format . to format the code
  2. Run dart analyze to check for issues
  3. Run dart test to ensure all tests pass

Libraries

deceiver
Deceiver - a fake HTTP response library for Dart's package:http.