HTTP Tests
- Introduction
- Faking Responses
- Making Assertions
- Preventing Stray Requests
- Response Factory
- Unfaking
- Full Example
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 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 — 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);
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!
[!TIP] Enable
preventStrayRequests()in tests that should be fully isolated from external services to catch accidental network calls early.
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
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> {
Future 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(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 {
Future 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(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);
});
});
}
[!TIP] Use
fake.stub()inside individual tests to override the default stub registered insetUp. This keeps the happy path insetUpand edge cases inline.
See Also
- Facade Testing —
Auth.fake(),Cache.fake(),Vault.fake(),Log.fake()