Mocking Bluetooth in Flutter: Updated
FlutterBluePlus is now static. How can we test it now?
I previously published an article on how to mock Bluetooth using Mockito in a Flutter app. One of the main points of the article was:
Note that you cannot mock static functions and variables with Mockito (such as
FlutterBluePlus.instance
).
Another point:
In order for
StartupManager
to be testable, it can’t callFlutterBluePlus.instance
on its own, it must get it as a parameter (dependency injection).
Since FlutterBluePlus version 1.10.0, there is no FlutterBluePlus.instance
. All functions of FlutterBluePlus
are now static. It is no longer possible to pass an instance of FlutterBluePlus
to anything.
In fact, it is no longer easily mockable.
But that is what will solve now :)
The Plan
- Wrap FlutterBluePlus in a mockable class
- Create an instance of the mockable class, and pass it to the relevant widgets and controllers
- Replace all calls to FlutterBluePlus with calls from the mockable instance
- In your test, mock FlutterBluePlusMockable instead of FlutterBluePlus
- Proceed as before
Step I: Create a wrapper for FlutterBluePlus
Note: I have submitted a pull request with this wrapper, it was added to MOCKING.md. So it can be copied from there as well.
The solution is simple, if a little annoying. An issue was opened on this subject, and the recommended solution was to create a wrapper for FlutterBluePlus, that makes all the static functions, well, not static.
Important! The code below is no longer up to date. Copy the latest version from Mocking.md.
/// mockable version of FlutterBluePlus
/// wraps all static FBP calls
import '../flutter_blue_plus.dart';
class FlutterBluePlusMockable {
/// Start a scan, and return a stream of results
/// - [timeout] calls stopScan after a specified duration
/// - [removeIfGone] if true, remove devices after they've stopped advertising for X duration
/// - [oneByOne] if true, we will stream every advertisement one by one, including duplicates.
/// If false, we deduplicate the advertisements, and return a list of devices.
/// - [androidUsesFineLocation] request ACCESS_FINE_LOCATION permission at runtime
Future<void> startScan({
List<Guid> withServices = const [],
Duration? timeout,
Duration? removeIfGone,
bool oneByOne = false,
bool androidUsesFineLocation = false,
}) {
return FlutterBluePlus.startScan(
withServices: withServices,
timeout: timeout,
removeIfGone: removeIfGone,
oneByOne: oneByOne,
androidUsesFineLocation: androidUsesFineLocation);
}
/// Gets the current state of the Bluetooth module
Stream<BluetoothAdapterState> get adapterState {
return FlutterBluePlus.adapterState;
}
/// Returns a stream of List<ScanResult> results while a scan is in progress.
/// - The list contains all the results since the scan started.
/// - The returned stream is never closed.
Stream<List<ScanResult>> get scanResults {
return FlutterBluePlus.scanResults;
}
/// are we scanning right now?
bool get isScanningNow {
return FlutterBluePlus.isScanningNow;
}
/// returns whether we are scanning as a stream
Stream<bool> get isScanning {
return FlutterBluePlus.isScanning;
}
/// Stops a scan for Bluetooth Low Energy devices
Future<void> stopScan() {
return FlutterBluePlus.stopScan();
}
/// Sets the internal FlutterBlue log level
void setLogLevel(LogLevel level, {color = true}) {
return FlutterBluePlus.setLogLevel(level, color: color);
}
LogLevel get logLevel {
return FlutterBluePlus.logLevel;
}
/// Checks whether the hardware supports Bluetooth
Future<bool> get isSupported {
return FlutterBluePlus.isSupported;
}
/// Return the friendly Bluetooth name of the local Bluetooth adapter
Future<String> get adapterName {
return FlutterBluePlus.adapterName;
}
/// Turn on Bluetooth (Android only),
Future<void> turnOn({int timeout = 60}) {
return FlutterBluePlus.turnOn(timeout: timeout);
}
/// Turn off Bluetooth (Android only),
@Deprecated('Deprecated in Android SDK 33 with no replacement')
Future<void> turnOff({int timeout = 10}) {
return FlutterBluePlus.turnOff();
}
/// Retrieve a list of connected devices
/// - The list includes devices connected by other apps
/// - You must still call device.connect() to connect them to *your app*
Future<List<BluetoothDevice>> get connectedSystemDevices {
return FlutterBluePlus.connectedSystemDevices;
}
/// Request Bluetooth PHY support
Future<PhySupport> getPhySupport() {
return FlutterBluePlus.getPhySupport();
}
/// Retrieve a list of bonded devices (Android only)
Future<List<BluetoothDevice>> get bondedDevices {
return FlutterBluePlus.bondedDevices;
}
}
Annoying? A bit. But not difficult as such.
Step II+III: Replace all FlutterBluePlus by FlutterBluePlusMockable
In your code, replace all instances of FlutterBluePlus
by an instance of FlutterBluePlusMockable
:
import 'package:my_app/mock/flutter_blue_plus_mockable.dart';
import 'package:my_app/service_locator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'connection/bluetooth_off/bluetooth_off_screen.dart';
import 'connection/find_devices/find_devices_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key);
//instance of FlutterBluePlus that will be passed
//throughout the app as necessary
FlutterBluePlusMockable bluePlusMockable = FlutterBluePlusMockable();//<--
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My app',
theme: ThemeData(
primarySwatch: Colors.lightGreen,
),
//this is based on the flutter_blue_plus example
home: StreamBuilder<BluetoothAdapterState>(
stream: bluePlusMockable.adapterState,
initialData: BluetoothAdapterState.unknown,
builder: (c, snapshot) {
final state = snapshot.data;
if (state == BluetoothAdapterState.on) {
// screen showing available devices - note that mockable
//is passed as a parameter
return FindDevicesScreen(
bluePlusMockable: bluePlusMockable,
);
}
//bluetooth off screen - shows a simple icon and the
//words "bluetooth off"
return BluetoothOffScreen(state: state);
}),
);
}
}
What did we do here?
- We created an instance of our FlutterBluePlusMockable class
- MyApp is based on flutter_blue_plus example from roughly a year ago. We listen to the adapter state. If it’s off we show a “Bluetooth off” screen, and if it’s on we scan for available devices. Note that the FindDevicesScreen must accept a FlutterBluePlusMockable instance.
Let’s look at the FindDevicesScreen
.
FindDevicesScreen: init, getPermissions and dispose
The screen has a FindDevicesManager
that handles the BLE stuff. So we need to initialize it in initState
. Note that it also receives a FlutterBluePlusMockable
instance.
We also need to get permissions because Android 13.
And we need to dispose of any open subscriptions in the manager, so we need a dispose
function.
class FindDevicesScreen extends StatefulWidget {
FindDevicesScreen({super.key, required this.bluePlusMockable});
//Instance of FlutterBluePlus
FlutterBluePlusMockable bluePlusMockable;
@override
State<FindDevicesScreen> createState() => _FindDevicesScreenState();
}
class _FindDevicesScreenState extends State<FindDevicesScreen> {
//manager that manages the ble stuff
late FindDevicesManager manager;
//do we have permissions?
bool gotPermissions = false;
@override
void initState() {
//initalize the manager witht eh FlutterBluePlus instance
manager = FindDevicesManager(bluePlusMockable: widget.bluePlusMockable);
//get permissions
getPermissions();
super.initState();
}
//get all bluetooth permissions - for android 13
Future<void> getPermissions() async {
bool ok = await Util.hasAcceptedPermissions();
setState(() {
gotPermissions = ok;
});
}
@override
void dispose() {
// clean up any open subscriptions
manager.stopAll();
super.dispose();
}
}
FindDevicesScreen: build function
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Find Devices'),
),
body: !gotPermissions //if we don't have permissions, don't scan
? const Center(child: Text("Waiting for permissions"))
: SingleChildScrollView( //else, show all the scan results
child: Column(
//create a list of scan results
children: <Widget>[
ValueListenableBuilder<List<ScanResult>>(
//this is from our instance:
valueListenable: manager.results,
builder: (c, value, _) => Column(
children: value.map(
(r) {
return ScanResultTile(//this is a flutter_blue widget
key: Key(r.device.remoteId.toString()),
result: r,
onTap: () {
if (r.getType() == Util.noneDevice) {//<--this is my extension
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text("Device not supported")));
return;
}
DeviceManager deviceManager;
if (r.getType() == Util.device1) {
deviceManager = Device1Manager(r.device);
} else {
deviceManager = Device2Manager(r.device);
}
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) {
return DeviceScreen(
manager: deviceManager);
}));
},
);
},
).toList(),
),
),
],
),
),
//scan button
floatingActionButton: ValueListenableBuilder<bool>(
valueListenable: manager.isScanning, //this is our instance too!
builder: (c, value, _) {
if (value) {
return FloatingActionButton(
child: Icon(Icons.stop),
onPressed: () => manager.stopScan(),//and this
backgroundColor: Colors.red,
);
} else {
return FloatingActionButton(
child: Icon(Icons.search),
onPressed: () => manager.startScan());//and this
}
},
),
);
}
The build
function first checks if we have permissions. If we have it, then it subscribes to the scan results through the manager, and displays each result in a column.
When the result is tapped, we detect what type of device it is using my device specific extension (which we want to test), and pass the correct DeviceManager
to the next screen.
In addition, there is a floating button to start/stop scanning for devices. The button listens to FlutterBluePlusMockable.isScanning
to show a different icon when scanning.
FindDevicesManager (Unit Under Test)
class FindDevicesManager {
//ValueNotifiers
ValueNotifier<List<ScanResult>> results =
ValueNotifier([]); //list of scan results
ValueNotifier<bool> isScanning = ValueNotifier(false); //are we scanning?
//StreamSubscriptions
StreamSubscription<List<ScanResult>>? resultSubscription; //scanned devices
StreamSubscription<bool>? scanningSubscription; //are we scanning stream
//moackable Bluetooth
FlutterBluePlusMockable bluePlusMockable;
//----------------------------------------------------
//constructor that accepts mocakable bluetooth
FindDevicesManager({required this.bluePlusMockable}) {
//initialize subscriptions
//are we scanning?
var scanningStream = bluePlusMockable.isScanning;
scanningSubscription = scanningStream.listen((newScanning) {
//update scanning notifier
isScanning.value = newScanning;
});
//scan results
var resultStream = bluePlusMockable.scanResults;
resultSubscription = resultStream.listen((event) {
//update results notifier
results.value = event.toList();
});
}
//-----------------------------------------------------
//cancel all subscriptions
void stopAll() {
scanningSubscription?.cancel();
resultSubscription?.cancel();
}
//start scanning for devices
Future<void> startScan() {
return bluePlusMockable.startScan(timeout: const Duration(seconds: 4));
}
//stop scanning for devices
Future<void> stopScan() {
return bluePlusMockable.stopScan();
}
}
The first part has the ValueNotifiers
: scanResult
notifier, and isScanning
notifier. In addition, it has the streamSubscriptions
, and the mockable version of FlutterBluePlus
.
The second part is the constructor, where we subscribe to the streams and update the ValueNotifiers
.
The third part has helper functions: stopping all the subscriptions and starting and stopping the scan.
Notice that all the FlutterBluePlus
functions such as startScan
, stopScan
, scanResults
and isScanning
use the mockable version.
Step IV-V: Mock using Mockito and continue as before
All we’ve done until now is make sure that we can mock FlutterBluePlus. As I explained in the previous article, you can create your test, find_devices_manager_test.dart
:
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
@GenerateMocks([
FlutterBluePlusMockable,//<-- note this!
BluetoothDevice,
ScanResult,
])
void main() {
group(
"Detect devices test",
() {
setUpAll(() {//this runs once before all the test
//we'll fill this in soon
});
setUp(() {//this runs before each test
//put code that needs to be reset here.
//e.g. SharedPreferences values that change during the test
});
test('detect device type 2', () async {
//check that if scanresult is of type device2 it creates Device2Manager
});
test('detect device type 1', () async {
//check that if scanresult is of type device1 it creates Device1Manager
});
test('detect device not supported', () async {
//check that if a wrong device was tapped, does not continue
});
// more tests here...
});
}
Make sure that you have FlutterBluePlusMockable and not FlutterBluePlus in your list of mocks to generate. Tell Mockito to build your mocks:
dart run build_runner build
And fill in the stubs and tests as before.
So my current test is now:
import 'dart:ffi';
import 'package:my_app/connection/find_devices/scan_result_extension.dart';
import 'package:my_app/mock/flutter_blue_plus_mockable.dart';
import 'package:my_app/util.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/connection/find_devices/find_devices_manager.dart';
import 'find_devices_manager_test.mocks.dart';
@GenerateMocks([ScanResult, FlutterBluePlusMockable, BluetoothDevice])
void main() {
//mocks:
final flutterBlue = MockFlutterBluePlusMockable();
//not a device1 or device2
final ScanResult notDeviceResult = MockScanResult();
final BluetoothDevice notDevice = MockBluetoothDevice();
//device1
final ScanResult device1Result = MockScanResult();
final BluetoothDevice device1Device = MockBluetoothDevice();
//device2
final ScanResult device2Result = MockScanResult();
final BluetoothDevice device2Device = MockBluetoothDevice();
//fake bluetooth address for devices
String addressnotDevice = "12:23:34:45:56:56";
String addressDevice1 = "12:23:34:45:56:57";
String addressDevice2 = "12:23:34:45:56:58";
group("find devices manager tests", () {
setUpAll(() {
when(notDeviceResult.device).thenReturn(notDevice);
when(notDeviceResult.rssi).thenReturn(0);
when(notDevice.name).thenReturn("blah");
when(notDevice.remoteId).thenReturn(DeviceIdentifier(addressnotDevice));
});
test(
'detect general device',
() async {
//stubs
//Now we create the results of the scan
List<List<ScanResult>> results = [
[notDeviceResult],
];
//and tell Mockito to return the results when asked
when(flutterBlue.scanResults).thenAnswer((_) {
return Stream.fromIterable(results);
});
//isScanning is always true fo rhte purposes of this test
when(flutterBlue.isScanning).thenAnswer((_) {
return Stream.fromIterable([true]);
});
//mock advertisement data - for device detection
when(notDeviceResult.advertisementData).thenAnswer((_) {
return AdvertisementData(
localName: "None",
txPowerLevel: 10,
connectable: true,
manufacturerData: <int, List<int>>{},
serviceData: <String, List<int>>{},
serviceUuids: []);
});
// UUT: The FindDevicesManager
FindDevicesManager manager =
FindDevicesManager(bluePlusMockable: flutterBlue);
//the actual test
await manager.startScan();
//expect identification as general device
expect(manager.results.value[0].getType(), Util.noneDevice);
},
);
test(
'detect device1 device',
() async {
//stubs
//Now we create the results of the scan
List<List<ScanResult>> results = [
[device1Result],
];
//and tell Mockito to return the results when asked
when(flutterBlue.scanResults).thenAnswer((_) {
return Stream.fromIterable(results);
});
//isScanning is always true fo rhte purposes of this test
when(flutterBlue.isScanning).thenAnswer((_) {
return Stream.fromIterable([true]);
});
//mock advertisement data - for device detection
when(device1Result.advertisementData).thenAnswer((_) {
return AdvertisementData(
localName: "None",
txPowerLevel: 10,
connectable: true,
manufacturerData: <int, List<int>>{
3180: [2]//this identifies the device
},
serviceData: <String, List<int>>{},
serviceUuids: []);
});
// UUT: The FindDevicesManager
FindDevicesManager manager =
FindDevicesManager(bluePlusMockable: flutterBlue);
//the actual test
await manager.startScan();
expect(manager.results.value[0].getType(), Util.device1Device);
},
);
test(
'detect device2 device',
() async {
//same as device1
},
);
});
}
Problem solved!
What elements do you test? What is your strategy in this case? Let me know in the comments.
For me, programming is a comfort and a solace, to have a problem that can be solved. It helps me through the surrounding misery.