Photo by Charles Forerunner on Unsplash
Introduction
As we saw on the previous entry, method channels allow us to invoke native code from a Flutter app.
From the testing point of view, method channels may look a challenge at first: how do we break the strong dependency on the native platform in order to get our code into test harness?
Luckily for us, Flutter includes some built-in features that make testing method channels a no-brainer!
Sample feature using a method channel
Let’s say we have a helper class in our project that opens the corresponding app stores so the user can update the app.
The redirection to the store is implemented as a web link, managed by the url_launcher flutter plugin. Under the hood, this plugin uses method channels to handle the links at native platform level.
A very simple implementation for this class would be something like:
import 'package:url_launcher/url_launcher.dart';
class AppStoreLauncherException implements Exception {
final String msg;
const AppStoreLauncherException(this.msg) : super();
}
class AppStoreLauncher {
const AppStoreLauncher() : super();
Future<void> launchWebStore({bool isAndroid = true}) async {
String url = 'https://apps.apple.com/...';
if (isAndroid) {
url = 'https://play.google.com/store/apps/details?id=...';
}
if (await canLaunch(url)) {
await launch(url);
} else {
throw AppStoreLauncherException('Could not launch $url');
}
}
}
Note that the “canLaunch()” and “launch()” methods are the ones provided by the plugin. If we want to test this class, we’ll have to mock the values they return. Let’s see the way to do it…
Workflow
In order to “mock” a method channel:
- Create a “fake” method channel using the same unique name
- Register a handler so the calls to native code are intercepted
- Stub the values returned by the calls on our “fake” method channel
- Add some optional “sensing variables”
1. Creating a fake channel
Instantiate a new object passing as parameter the name of the channel we want to mock. Since names must be unique, they usually look like an inversed package name. In the current example, we must use the url launcher plugin name:
MethodChannel mockChannel = const MethodChannel('plugins.flutter.io/url_launcher');
2. Registering the handler
All the information exchanged between Flutter and the native platform is sent as a message. If we want to control the values exchanged, we must use the “TestDefaultBinaryMessengerBinding” mixin.
The previous class delegates the messaging features to a property called “defaultBinaryMessenger“: so that’s the object we have to use in order to get control over the messages exchanged.
“TestDefaultBinaryMessenger” API allow us to mock the native code invocations by using “setMockMethodCallHandler()“:
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(mockChannel, handler);
This method receives as arguments:
- the “fake” method channel
- a function handler that performs the actual stubbing for the values returned. It simply checks the method invoked (passed as parameter) and returns the value we prefer for that call
So putting it all together:
Future<bool>? handler(MethodCall methodCall) async {
if (methodCall.method == "canLaunch") {
return true;
}
return false;
}
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(mockChannel, handler);
3. Stubbing values
Since the previous approach is not very flexible, we can wrap the code in a custom method that receives as optional parameters the return values we want to use and registers the handler with them. This way, we can control the “fake” channel at runtime:
void _mockChannelValues({bool canLaunch = true, bool launch = true}) {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.setMockMethodCallHandler(
mockChannel,
(MethodCall methodCall) async {
if (methodCall.method == "canLaunch") {
return canLaunch;
} else if (methodCall.method == "launch") {
return launch;
}
return false;
}
);
}
4. Adding optional “sensing variables”
“Sensing variables” are basically redundant properties that give you a better insight over a snippet of code. Although they’re usually added in production code, we can use them in test code as well, in order to find out what’s happening under the hood.
In this case, we can log/register every call invoked in our “fake” method channel with a sensing variable. Later we can check these logs to make sure everything went as expected and perform some assertions.
After all, we only have to declare a new variable:
late List<MethodCall> fakeLog;
and modify it every time a method is invoked:
void _mockChannelValues({bool canLaunch = true, bool launch = true}) {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.setMockMethodCallHandler(
mockChannel,
(MethodCall methodCall) async {
fakeLog.add(methodCall);
//XXX: more code here...
}
);
}
Later we can check its contents, looking for a specific method or a given number of invocations:
expect(fakeLog.length, 2);
expect(
fakeLog.map((e) => e.method), equals(<String>[
'canLaunch',
'launch',
]));
Bonus: example unit test
Testing if the Android PlayStore is actually launched when using our helper class:
test('When android store both canLaunch and launch are invoked', () async {
_mockChannelValues(canLaunch: true, launch: true);
await launcher.launchWebStore(isAndroid: true);
expect(fakeLog.length, 2);
expect(
fakeLog.map((e) => e.method),
equals(<String>[
'canLaunch',
'launch',
]));
});
Troubleshotting
Since we’re simulating the usage of 3rd party libraries, native code, etc, we must make sure that the Flutter testing environment is properly configured before running the tests, otherwise we will run into some nasty error.
So make sure you invoke:
TestWidgetsFlutterBinding.ensureInitialized();
before running your tests
Sample code
As usual, source code is available here.
Write you next time!