HTTP Tests
Http.fake() lets you stub HTTP responses by URL or callback so controller and service tests run without a live server, with assertions to verify which requests were actually made.
On this page
Introduction#
Magic provides a first-class HTTP faking API that lets you swap the real network driver with a FakeNetworkDriver during tests. All requests are recorded and no real network traffic is made. This approach is inspired by Laravel's Http::fake() and requires no third-party mocking libraries.
setUp(() {
MagicApp.reset();
Magic.flush();
Http.fake(); // Intercept all HTTP requests
});
tearDown(() {
Http.unfake(); // Restore real driver
});
Faking Responses#
Call Http.fake() before the code under test runs. When called with no arguments, every request returns a 200 response with empty data.
final fake = Http.fake();
final response = await Http.get('/users');
expect(response.successful, isTrue);
expect(response.data, equals({}));
URL Pattern Stubs#
Pass a Map<String, MagicResponse> to stub specific URL patterns. Patterns support * as a wildcard.
final fake = Http.fake({
'users/*': Http.response({'id': 1, 'name': 'Alice'}, 200),
'posts': Http.response([], 200),
'auth/login': Http.response({'token': 'test-token'}, 200),
});
final user = await Http.get('/users/42');
expect(user['name'], 'Alice');
final posts = await Http.get('/posts');
expect(posts.data, isEmpty);
Pattern matching is done after stripping the leading / so both /users/42 and users/42 match the pattern users/*.
Callback Stubs#
Pass a FakeRequestHandler — a function that receives a MagicRequest and returns a MagicResponse, or a Future<MagicResponse> — for dynamic stubbing logic.
final fake = Http.fake((request) {
if (request.url.contains('admin')) {
return Http.response({'error': 'Forbidden'}, 403);
}
return Http.response({'ok': true}, 200);
});
final adminResponse = await Http.get('/admin/users');
expect(adminResponse.forbidden, isTrue);
final publicResponse = await Http.get('/users');
expect(publicResponse.successful, isTrue);
Holding a request in flight
An async handler lets a test keep a request OUTSTANDING while it does
something else, which is the only way to script a concurrency case: what
happens when a sign-out, a navigation or a second call arrives before the
first response lands.
final gate = Completer<void>();
Http.fake((request) async {
await gate.future;
return Http.response({'ok': true}, 200);
});
final inFlight = controller.submit(); // outstanding, not finished
await controller.signOut(); // arrives mid-request
gate.complete();
await inFlight;
Making Assertions#
Http.fake() returns the FakeNetworkDriver instance. Use it to assert on recorded requests after the code under test runs.
assertSent#
Assert that at least one recorded request matches the predicate.
final fake = Http.fake();
await Http.post('/users', data: {'name': 'Bob'});
fake.assertSent((request) => request.url.contains('users'));
fake.assertSent(
(request) => request.method == 'POST' && request.url == '/users',
);
assertNotSent#
Assert that no recorded request matches the predicate.
fake.assertNotSent((request) => request.url.contains('payments'));
assertNothingSent#
Assert that no requests were made at all.
final fake = Http.fake();
// Code that should not touch the network
doSomethingLocal();
fake.assertNothingSent();
assertSentCount#
Assert an exact number of requests were recorded.
final fake = Http.fake();
await Http.get('/users');
await Http.get('/users/1');
fake.assertSentCount(2);
recorded#
Access the full list of recorded (MagicRequest, MagicResponse) pairs for custom assertions.
final fake = Http.fake();
await Http.post('/orders', data: {'item': 'Widget'});
final pair = fake.recorded.first;
expect(pair.$1.method, 'POST');
expect(pair.$2.statusCode, 200);
Preventing Stray Requests#
Call preventStrayRequests() on the fake driver to throw a StrayRequestException whenever an unmatched request is made. This ensures every HTTP call in your test has an explicit stub.
final fake = Http.fake({
'users': Http.response([], 200),
})..preventStrayRequests();
// This is fine — matched by stub
await Http.get('/users');
// This throws StrayRequestException — no matching stub
await Http.get('/notifications'); // throws!
Response Factory#
Http.response() is a convenience factory for building MagicResponse objects to use as stub responses.
// 200 with empty data (default)
Http.response()
// 200 with a Map body
Http.response({'id': 1, 'name': 'Alice'})
// Custom status code
Http.response({'message': 'Not found'}, 404)
// List body
Http.response([{'id': 1}, {'id': 2}], 200)
// 422 validation errors
Http.response({
'message': 'The given data was invalid.',
'errors': {
'email': ['The email field is required.'],
},
}, 422)
Unfaking#
Call Http.unfake() in tearDown to remove the fake driver from the IoC container and restore the real network driver for subsequent tests.
tearDown(() {
Http.unfake();
});
Alternatively, you can call fake.reset() on the driver instance to clear recorded requests and stubs without restoring the real driver. This is useful when reusing the same fake across multiple test cases.
setUp(() {
MagicApp.reset();
Magic.flush();
});
// In a group where all tests share one fake:
final fake = Http.fake();
test('first test', () async {
await Http.get('/a');
fake.assertSentCount(1);
fake.reset(); // Clear for next test
});
test('second test', () async {
await Http.get('/b');
fake.assertSentCount(1);
});
Full Example#
A controller that fetches a user profile and the test verifying it:
// lib/controllers/profile_controller.dart
class ProfileController extends MagicController
with MagicStateMixin<Map<String, dynamic>>, ValidatesRequests {
Future<void> load(String userId) async {
setLoading();
final response = await Http.show('users', userId);
if (response.successful) {
setSuccess(response.data as Map<String, dynamic>);
} else {
setError(response.firstError ?? 'Failed to load profile');
}
}
}
// test/http/profile_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
void main() {
group('ProfileController', () {
late ProfileController controller;
late FakeNetworkDriver fake;
setUp(() {
MagicApp.reset();
Magic.flush();
controller = ProfileController();
Magic.put<ProfileController>(controller);
fake = Http.fake();
});
tearDown(() {
Http.unfake();
});
test('sets success state on 200 response', () async {
fake.stub(
'users/*',
Http.response({'id': '42', 'name': 'Alice'}, 200),
);
await controller.load('42');
expect(controller.isSuccess, isTrue);
expect(controller.rxState?['name'], 'Alice');
fake.assertSent((r) => r.url.contains('users/42'));
});
test('sets error state on failure', () async {
fake.stub(
'users/*',
Http.response({'message': 'User not found'}, 404),
);
await controller.load('999');
expect(controller.isError, isTrue);
});
test('does not call network when userId is empty', () async {
// No real call should be made
fake.assertNothingSent();
});
});
}
Testing fetchList and fetchOne#
Controllers that use fetchList() or fetchOne() from MagicStateMixin can be tested with Http.fake() the same way as any other HTTP call. Stub the URL the helper will request, then assert on the resulting controller state.
fetchList#
// lib/controllers/project_controller.dart
class ProjectController extends MagicController
with MagicStateMixin<List<Project>> {
Future<void> loadProjects(String teamId) =>
fetchList('teams/$teamId/projects', Project.fromMap);
}
// test/http/project_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
void main() {
group('ProjectController.fetchList', () {
late ProjectController controller;
late FakeNetworkDriver fake;
setUp(() {
MagicApp.reset();
Magic.flush();
controller = ProjectController();
Magic.put<ProjectController>(controller);
fake = Http.fake({
'teams/*/projects': Http.response({
'data': [
{'id': 1, 'name': 'Project A'},
{'id': 2, 'name': 'Project B'},
],
}, 200),
});
});
tearDown(() => Http.unfake());
test('sets success state with parsed list', () async {
await controller.loadProjects('team-1');
expect(controller.isSuccess, isTrue);
expect(controller.rxState?.length, 2);
expect(controller.rxState?.first.name, 'Project A');
fake.assertSent((r) => r.url.contains('teams/team-1/projects'));
});
test('sets empty state when data list is empty', () async {
fake.stub('teams/*/projects', Http.response({'data': []}, 200));
await controller.loadProjects('team-1');
expect(controller.isEmpty, isTrue);
});
test('sets error state on failed response', () async {
fake.stub(
'teams/*/projects',
Http.response({'message': 'Unauthorized'}, 401),
);
await controller.loadProjects('team-1');
expect(controller.isError, isTrue);
});
});
}
fetchOne#
// lib/controllers/project_detail_controller.dart
class ProjectDetailController extends MagicController
with MagicStateMixin<Project> {
Future<void> loadProject(String id) =>
fetchOne('projects/$id', Project.fromMap);
}
// test/http/project_detail_controller_test.dart
void main() {
group('ProjectDetailController.fetchOne', () {
late ProjectDetailController controller;
late FakeNetworkDriver fake;
setUp(() {
MagicApp.reset();
Magic.flush();
controller = ProjectDetailController();
Magic.put<ProjectDetailController>(controller);
fake = Http.fake({
'projects/*': Http.response({
'data': {'id': 1, 'name': 'Project A'},
}, 200),
});
});
tearDown(() => Http.unfake());
test('sets success state with parsed model', () async {
await controller.loadProject('1');
expect(controller.isSuccess, isTrue);
expect(controller.rxState?.name, 'Project A');
fake.assertSent((r) => r.url.contains('projects/1'));
});
test('sets error state when data key is absent', () async {
fake.stub('projects/*', Http.response({'meta': {}}, 200));
await controller.loadProject('1');
expect(controller.isError, isTrue);
});
});
}
See Also#
- Facade Testing —
Auth.fake(),Cache.fake(),Vault.fake(),Log.fake()