skip to content

In a Flutter test, how do you stub a MethodChannel so code that calls a plugin runs without the native side?

level: middleimportance: should knowfreq 36%

answer

  1. the test binary messenger
  2. TestDefaultBinaryMessengerBinding.instance
  3. handler receives a MethodCall
  4. throw PlatformException for errors
  5. old MethodChannel shim is deprecated

basics

~10 s

Register a handler on the test messenger: TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async => ...), return a value per call.method, throw PlatformException for errors, and pass null to remove it.

solid answer

~30 s

`flutter_test` replaces the binary messenger with a `TestDefaultBinaryMessenger`. Calling `setMockMethodCallHandler(channel, handler)` on it — through `tester.binding.defaultBinaryMessenger` or `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger` — intercepts outgoing calls on that channel's name: the handler gets the decoded `MethodCall`, its return value is encoded as the success result, and a `PlatformException` it throws becomes an error result. In a plain `test()` you first call `TestWidgetsFlutterBinding.ensureInitialized()`. Pass `null` to remove the handler. The older `MethodChannel.setMockMethodCallHandler` is only a deprecated shim, and `setMockStreamHandler` does the same for an `EventChannel`.

code

dart · 31 lines
dart
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();
  const channel = MethodChannel('app/location');
  final messenger =
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;

  tearDown(() => messenger.setMockMethodCallHandler(channel, null));

  test('currentCity returns the native answer', () async {
    messenger.setMockMethodCallHandler(channel, (call) async {
      expect(call.method, 'currentCity');
      return 'Oslo';
    });

    expect(await LocationService(channel).currentCity(), 'Oslo');
  });

  test('a PlatformException from the host surfaces to Dart', () async {
    messenger.setMockMethodCallHandler(channel, (call) async {
      throw PlatformException(code: 'denied');
    });

    expect(
      LocationService(channel).currentCity(),
      throwsA(isA<PlatformException>()),
    );
  });
}

go deeper

for a junior

Remember that native code does not run in flutter test, and that the test messenger's setMockMethodCallHandler answers a MethodChannel instead.

for a middle

Explain the flow: the handler receives a decoded MethodCall, its return value becomes the success result, and a thrown PlatformException becomes an error result.

for a senior

Choose the seam: stub channels you own, but wrap third-party plugins or fake their platform interface so tests do not depend on private method names.

for a principal

Decide how plugin boundaries are tested across the app: wrappers per plugin, shared fakes, and which paths are left to on-device tests.

## Why a channel needs stubbing Flutter plugins talk to Android and iOS code over **platform channels**: a `MethodChannel` encodes a method name and arguments, sends them through the engine's **binary messenger**, and decodes the reply. In a `flutter test` run there is no Android or iOS host behind the channel, so a call that reaches the real messenger gets no reply and typically fails with `MissingPluginException`. To test Dart code that uses a plugin — say a `LocationService` asking the native side for the device's city so the forecast screen can load it — you answer the channel yourself. ## The test messenger `flutter_test` installs its own binding. `TestDefaultBinaryMessengerBinding` swaps the messenger for a **`TestDefaultBinaryMessenger`**, which checks a table of mock handlers before forwarding a message. You reach it two ways: - `tester.binding.defaultBinaryMessenger` inside `testWidgets`; - `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger` anywhere, after `TestWidgetsFlutterBinding.ensureInitialized()` in a plain `test()`. ## Stubbing method calls ```dart const channel = MethodChannel('app/location'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { case 'currentCity': return 'Oslo'; case 'requestPermission': throw PlatformException(code: 'denied'); default: return null; } }); ``` What happens inside, per the `flutter_test` source: 1. The handler is registered under `channel.name`, so it intercepts **outgoing** calls on that channel. 2. Each message is decoded with the channel's codec into a **`MethodCall`** carrying `method` and `arguments`. 3. The handler's return value is encoded as a **success envelope**; `null` is a legal result. 4. A thrown **`PlatformException`** becomes an **error envelope** with its `code`, `message` and `details`, so the caller's `invokeMethod` throws a matching `PlatformException`. 5. A thrown `MissingPluginException` makes the reply empty, which the caller sees as `MissingPluginException` — useful to test the "plugin not available" branch. 6. Any other exception is reported as an error envelope with code `'error'`. Calling `setMockMethodCallHandler(channel, null)` removes the handler. The API docs state that registered handlers are cleared after each test; many suites still reset explicitly in `tearDown` so intent is visible when a handler is shared across tests. ## Related APIs | API | Use | |---|---| | `setMockMethodCallHandler(MethodChannel, handler)` | answer method calls with decoded `MethodCall`s | | `setMockStreamHandler(EventChannel, MockStreamHandler)` | emit events on an `EventChannel` | | `setMockDecodedMessageHandler(BasicMessageChannel, handler)` | answer messages decoded with a `MessageCodec` | | `setMockMessageHandler(String name, handler)` | raw `ByteData` access | | `checkMockMessageHandler(name, handler)` | assert which handler is registered | | `allMessagesHandler` | intercept every channel at once | ## The deprecated form Older tests call `channel.setMockMethodCallHandler(...)` directly on the `MethodChannel`. Those methods moved out of `package:flutter` into `flutter_test`; what remains on `MethodChannel` is an extension shim marked deprecated since v3.9, which forwards to the test messenger. New code should call the messenger form. ## Prefer a seam above the channel Stubbing the channel ties the test to a plugin's **private method names and argument shapes** — the strings `'currentCity'` and `'requestPermission'` here. For a third-party plugin that is someone else's implementation detail, and a plugin update can rename them. Two better seams: - wrap the plugin in your own class (`LocationService`) and give the view model a fake or mocktail mock of it; - for federated plugins, register a fake of the plugin's **platform interface** class instead of answering its channel. Channel stubbing is the right tool when **you own the channel** — your app's own `MethodChannel` — and the Dart side's encoding and error handling are what the test is about. ## Asserting what Dart sent The handler is also the place to check the outgoing half of the contract. Because it receives the decoded `MethodCall`, a test can assert on `call.method` and on `call.arguments` — for example that the Dart side sends `{'accuracy': 'coarse'}` as a map rather than a positional list — before returning a reply. Collecting calls into a list and asserting on it after the action keeps the handler itself simple: ```dart final calls = <MethodCall>[]; messenger.setMockMethodCallHandler(channel, (call) async { calls.add(call); return 'Oslo'; }); ``` This checks only the Dart half. Whether the Kotlin or Swift side accepts those arguments is a question for an on-device integration test, which runs the real host code.

  • What does the caller see if the mock handler throws PlatformException(code: 'denied')?
    The test messenger encodes it as an error envelope with the same `code`, `message` and `details`, so `invokeMethod` on the Dart side throws a `PlatformException` with code `'denied'` — exactly what a real native error would produce.
  • How do you feed events into code listening to an EventChannel in a test?
    Call `setMockStreamHandler(eventChannel, handler)` on the test messenger with a `MockStreamHandler`. Its `onListen` receives a sink through which you emit events, errors or end-of-stream, and the Dart side's `receiveBroadcastStream()` sees them.

The test messenger is a switchboard operator for an office whose far end is empty: Dart still dials the same extension, but the operator picks up and reads from your script, including saying "the line is refused" when you want an error.

saying these in an interview costs you the question

  • Calls the deprecated MethodChannel.setMockMethodCallHandler shim in new code
  • Expects a plugin's real native code to run under flutter test
  • Returns an error map from the handler instead of throwing PlatformException
  • Stubs a third-party plugin's private channel method names everywhere
  • Uses the test messenger in a plain test() without initialising the binding