Front-end work often starts before the backend is ready. The API is still being designed, or it exists but you are on a train, or you have to demo the app and cannot depend on a server. The usual answer is a few hard-coded lists scattered through the code, which then have to be found and removed later.
A fake backend is a cleaner answer: the app keeps making real HTTP calls through its real client, and something answers them from local JSON files. But a good fake has to do more than return a file. Real APIs are slow, they fail, and they remember what you posted. If the fake does none of that, the loading states, the error screens and the "create then list" flows are never exercised.
mock_api_assets is a fake backend made of JSON fixtures. It has route matching, templated fixtures, simulated latency, error injection, a stateful CRUD store, switchable scenarios, and adapters for Dio and package:http.
What it does
A router. MockRouter matches on the method and a path pattern. :id captures one segment, * matches one segment, and ** (last segment only) matches the rest of the path. Query-string constraints are optional. The first match wins, and priority: moves a route ahead of routes registered earlier. A trailing slash is ignored, and a basePath such as /api/v1 can be stripped before matching.
Handlers. MockHandler.fixture(path) serves a fixture file. MockHandler.json(value) serves an inline JSON value. MockHandler.callback(...) gets the path params, query, headers and parsed body, and returns a response. MockHandler.paginated(...) and MockHandler.paginatedList(...) slice a list by page and limit.
Templating. Placeholders in fixtures and inline JSON are filled from the request: {{params.id}}, {{query.q}}, {{headers.x-token}}, {{body.items.0.sku}}, {{method}}, {{path}} and {{now}}. A string that is exactly one placeholder keeps the value's type, and the filters | int, | num and | bool convert it.
Pluggable fixture loaders. MapFixtureLoader keeps fixtures in memory. CallbackFixtureLoader calls a function you give it, for example Flutter's rootBundle.loadString. FileFixtureLoader reads from disk and lives in package:mock_api_assets/io.dart.
Latency. MockLatency.fixed, .uniform or .normal. Random values come from an injectable Random, and the delay function is injectable too, so tests can run on a fake clock.
Error injection. A global failure rate, errors for specific routes with an optional hit count, and "fail the next N calls". Faults are HTTP statuses or network faults: timeout, connection refused, connection reset and host lookup.
Stateful CRUD. router.resource('/todos', seed: [...]) registers list, create, read, replace, update and delete routes over an in-memory collection. POST, then GET, returns what was posted. router.store.reset() restores the seed.
Scenarios. Named sets of overrides you switch at runtime: route overrides, a latency, a fault for every request, or a failure rate. Think "empty-state", "server-down" and "slow-3g". Several can be active at once.
Request log. Assert on router.calls, router.callsTo('POST', '/todos') and router.wasCalled(...).
Helpful 404s. An unmatched request gets a 404 JSON body that lists the nearest registered routes.
Adapters. MockDioAdapter from package:mock_api_assets/dio.dart and MockHttpClient from package:mock_api_assets/http.dart. The core library imports neither.
How to use it
Add it from GitHub:
dependencies: mock_api_assets: git: https://github.com/AbdeldjalilChougui/mock_api_assets With Dio:
import 'package:dio/dio.dart'; import 'package:mock_api_assets/dio.dart'; import 'package:mock_api_assets/mock_api_assets.dart'; Future<void> main() async { final router = MockRouter( loader: MapFixtureLoader({ 'users/detail.json': {'id': '{{params.id | int}}', 'name': 'User'}, }), latency: const MockLatency.uniform( Duration(milliseconds: 200), Duration(milliseconds: 800), ), ) ..get('/users/:id', MockHandler.fixture('users/detail.json')) ..get('/orders/queue', MockHandler.json([]), priority: 10) ..get('/orders/:id', MockHandler.callback((req) { return MockResponse.json({'id': req.pathParams['id'], 'q': req.query}); })) ..resource('/todos', seed: [ {'id': 1, 'title': 'Buy milk'}, ]); final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')) ..httpClientAdapter = MockDioAdapter(router); await dio.post<Object?>('/todos', data: {'title': 'Walk dog'}); final todo = await dio.get<Object?>('/todos/2'); // {id: 2, title: Walk dog} print(todo.data); } The rest of the app does not change: it still calls dio.get and dio.post. With package:http, you swap the client instead:
final router = MockRouter()..get('/ping', MockHandler.json({'pong': true})); final http.Client client = MockHttpClient(router); final res = await client.get(Uri.parse('https://api.example.com/ping')); print(res.body); // {"pong":true} Fixtures from Flutter assets
The package does not depend on Flutter, but CallbackFixtureLoader takes any String -> Future<String> function:
final router = MockRouter( loader: CallbackFixtureLoader(rootBundle.loadString, prefix: 'assets/mocks/'), )..get('/users/me', MockHandler.fixture('auth/me.json')); Every asset directory must be listed under flutter: assets: in pubspec.yaml. Otherwise the fixture is reported as missing. For command-line tools and tests, use FileFixtureLoader('test/fixtures').
Making it fail on purpose
router.faults.failNext(2, MockFault.status(503)); // next two calls router.faults.failNext(1, MockFault.timeout()); // then a timeout router.faults.failRoute('POST', '/orders/:id/confirm', MockFault.status(409, body: {'detail': 'Already confirmed'}), times: 1); router.faults.failureRate = 0.1; // 10% of calls get a 500 router.faults.clear(); Status faults come back as ordinary responses. Network faults make router.handle throw MockNetworkException. The Dio adapter turns that into a DioException (connectionTimeout for a timeout, connectionError otherwise), and the http client turns it into an http.ClientException. So your error handling receives the exception types those clients normally throw.
Scenarios
router ..defineScenario(MockScenario('empty-state', routes: [ MockRoute('GET', '/todos', MockHandler.json(<Object?>[])), ])) ..defineScenario(MockScenario('server-down', fault: MockFault.status(503))) ..defineScenario(const MockScenario('slow-3g', latency: MockLatency.normal( Duration(milliseconds: 1500), Duration(milliseconds: 400)))); router.activateScenario('empty-state'); // only this one router.activateScenarios(['slow-3g', 'empty-state']); // stacked, later wins router.clearScenarios(); Scenario routes are tried before the router's own routes. A scenario fault applies to every request, including requests that match no route.
In tests
expect(router.callsTo('POST', '/todos'), hasLength(1)); expect(router.calls.where((c) => c.response?.statusCode == 404), isEmpty); router.reset(); // log, faults, scenarios and store To test latency without real waiting, inject random: and run the code inside fakeAsync from package:fake_async. You can also pass delay: (d) async {} to skip waits entirely.
Things to know
-
dioandhttpare both regular dependencies. The adapters are in separate libraries, and the core imports neither package. But pub has to resolve both for the adapters to compile, and Dart has no optional dependencies. Dart compilers drop libraries you never import, so an app that uses only one adapter does not ship the other. - Text bodies only. Bodies are handled as UTF-8 text. Multipart and binary request bodies reach handlers as a raw string and are not parsed, and responses are JSON or text only.
- No streaming, server-sent events, WebSockets, cookies or redirects.
- Templating fills values, it does not build structure. It replaces placeholders inside JSON string values. For loops or conditionals, use a callback handler.
- The CRUD store is in memory. Restarting the app restores the seed.
- Latency comes before faults. A timeout fault fails after the simulated latency, not after a configured client timeout.
- Dio cancellation during a simulated delay is not tested. It is handled by Dio itself, and the adapter has no cancellation logic of its own.
The package ships no datasets. The fixtures are yours.
Why I built it
Serving a JSON file in place of a response is the easy tenth of a fake backend. The states that are hard to build and easy to forget are the others: the spinner on a slow connection, the retry after a 503, the empty list, the timeout, the item that should appear in the list right after you create it.
I wanted those to be one line each, and switchable while the app runs, so a screen can be checked in "empty-state" or "slow-3g" without editing fixtures. I also wanted the same router to serve two jobs: running the app without a network, and testing, with a request log to assert on and a clock that tests can control. Plugging in at the HTTP client level, with Dio and package:http adapters, means the code under test is the real code, not a mocked repository.
Get the code
The source, the README with every option, the example and the tests are on GitHub: https://github.com/AbdeldjalilChougui/mock_api_assets. It's MIT licensed. Issues and pull requests are welcome.
Originally published at abdeldjalilchougui.biz.