TL;DR: flutter_blue_plus exposes everything through static methods, so you can't inject or mock it directly. The fix is an abstraction layer: wrap the plugin behind an interface, inject that interface everywhere, and swap in a fake during tests. Do that and you can unit-test scanning, connection, and notification logic with zero hardware — using mocktail or mockito — and reserve real devices for a thin integration layer.
If you've ever tried to write a unit test for BLE code in Flutter, you already know the wall you hit. You call FlutterBluePlus.startScan(), the test runner throws MissingPluginException, and you realize there's no BLE stack inside flutter test. There's no radio, no adapter, no peripheral — just a Dart VM with no idea what Bluetooth is.
The instinct is to give up and "test it manually on a device." That works right up until you have retry logic, MTU negotiation, a reconnection state machine, and a characteristic parser that all need to behave correctly under conditions you can't reproduce on demand — a device that disconnects mid-write, a notification that arrives out of order, an adapter that flips off. You can't manufacture those scenarios with a physical peripheral on a Tuesday afternoon. You can manufacture all of them in a test.
This guide walks through how to structure a Flutter BLE app so it's actually testable, how to mock flutter_blue_plus, and where physical hardware still earns its place. The techniques apply whether you're testing a heart-rate monitor, an ESP32 firmware updater, or a multi-device mesh.
Why flutter_blue_plus is hard to mock
Here's the root cause. Since version 1.10.0, flutter_blue_plus deprecated FlutterBluePlus.instance and moved everything to static methods. Scanning, adapter state, connection — all of it is called statically:
// You call the plugin like this — statically, everywhere.
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 4));
FlutterBluePlus.scanResults.listen((results) { /* ... */ });
final state = await FlutterBluePlus.adapterState.first;
Static calls are the enemy of testability. There's no object to pass in, no constructor to intercept, no seam where a test can substitute a fake. Mockito and mocktail both work by creating a fake object that implements a type — and a bag of static functions is neither. This is exactly why issues like "how to mock FlutterBluePlus" keep showing up in the package's tracker. The plugin isn't badly designed; static access is genuinely convenient in app code. It's just hostile to tests unless you add a seam yourself.
That seam is an abstraction layer.
Step 1: Wrap the plugin behind an interface
The single most valuable move you can make is to stop calling flutter_blue_plus directly from your app logic. Instead, define an interface that describes what your app needs from BLE — not everything the plugin can do, just the surface you actually use.
/// The BLE capabilities YOUR app depends on.
abstract class BleService {
Stream<BluetoothAdapterState> get adapterState;
Stream<List<ScanResult>> get scanResults;
Future<void> startScan({Duration timeout});
Future<void> stopScan();
Future<void> connect(BluetoothDevice device);
Stream<List<int>> subscribe(BluetoothCharacteristic characteristic);
}
Then write exactly one implementation that forwards to the real plugin. The community pattern (and the one the package's own MOCKING.md points at) is a class often called FlutterBluePlusMockable — a thin wrapper that turns each static call into an instance method:
class RealBleService implements BleService {
@override
Stream<List<ScanResult>> get scanResults => FlutterBluePlus.scanResults;
@override
Future<void> startScan({Duration timeout = const Duration(seconds: 4)}) {
return FlutterBluePlus.startScan(timeout: timeout);
}
// ...the rest simply delegate to the static API.
}
The wrapper contains no logic — it just delegates. That matters, because the wrapper itself is the one piece you won't unit test (there's nothing to test but forwarding). Everything above it — your scanning controller, your connection state machine, your parsers — now depends on the BleService interface, which you can replace at will.
If you're building your app structure from scratch, our complete Flutter BLE app walkthrough sets up exactly this kind of layered architecture from the first commit.
Step 2: Inject the service instead of reaching for it
An abstraction only helps if your code receives it rather than constructing it. That's dependency injection, and it's nothing fancier than "pass it in":
class DeviceScanner {
DeviceScanner(this._ble);
final BleService _ble;
Future<List<ScanResult>> scanForHeartRateMonitors() async {
await _ble.startScan(timeout: const Duration(seconds: 4));
final results = await _ble.scanResults.first;
return results
.where((r) => r.advertisementData.serviceUuids
.contains(Guid('180D')))
.toList();
}
}
In production you hand DeviceScanner a RealBleService. In a test you hand it a fake. The class can't tell the difference — and that's the whole point.
Step 3: Write the mock and the test
With the seam in place, testing is ordinary Flutter testing. mocktail is the cleaner choice in 2026 because it needs no code generation and no build_runner step — you just subclass Mock:
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
class MockBleService extends Mock implements BleService {}
void main() {
test('filters scan results down to heart-rate monitors', () async {
final ble = MockBleService();
when(() => ble.startScan(timeout: any(named: 'timeout')))
.thenAnswer((_) async {});
when(() => ble.scanResults)
.thenAnswer((_) => Stream.value(_fakeScanResults()));
final scanner = DeviceScanner(ble);
final found = await scanner.scanForHeartRateMonitors();
expect(found, hasLength(1));
verify(() => ble.startScan(timeout: any(named: 'timeout'))).called(1);
});
}
No radio. No adapter. No MissingPluginException. The test runs in milliseconds and, crucially, lets you assert on the logic — that you filtered by the 0x180D service UUID, that you called startScan exactly once, that you stopped scanning afterward.
Testing streams: the part everyone gets wrong
BLE in Flutter is stream-heavy. Connection state, adapter state, and notifications all arrive as Streams, and that's where naive tests fall apart. A mock that returns Stream.value(...) emits once and closes — but your reconnection logic probably expects a sequence of states over time.
Use a StreamController when you need to push events on your own schedule:
final stateController = StreamController<BluetoothConnectionState>();
when(() => ble.connectionState(any()))
.thenAnswer((_) => stateController.stream);
// Now drive the timeline by hand inside the test:
stateController.add(BluetoothConnectionState.connected);
await Future<void>.delayed(Duration.zero);
stateController.add(BluetoothConnectionState.disconnected); // simulate a drop
This is how you test the scenarios you can't reproduce with real hardware: a disconnect at the worst possible moment, a notification burst, an adapter that powers off mid-transfer. If you're testing reconnection specifically, pair this with the strategy in our Flutter BLE auto-reconnect guide — the state machine described there is a natural unit-test target.
Test your parsers directly — no mocking required
Not everything needs a mock. The highest-value, lowest-effort tests in a BLE app are the ones on your byte-level parsing, because that code is pure Dart with no plugin dependency at all.
// A heart-rate measurement parser (GATT 0x2A37). Pure logic, no BLE needed.
int parseHeartRate(List<int> value) {
final is16Bit = (value[0] & 0x01) == 0x01;
return is16Bit ? (value[2] << 8) | value[1] : value[1];
}
test('decodes an 8-bit heart-rate measurement', () {
expect(parseHeartRate([0x00, 72]), 72);
});
Isolate every encode/decode helper into a plain function and hammer it with test vectors — boundary values, the 16-bit flag, empty payloads, malformed frames. This is where bugs actually live, and it costs nothing to cover. For the structure behind these payloads, see our breakdown of GATT profiles and reading and writing characteristics.
Where real hardware still matters
Mocks prove your logic is correct. They can't prove the plugin talks to a real radio correctly, that permissions resolve on a physical device, or that your firmware and app agree on a protocol. For that you need a thin layer of integration tests using the Flutter SDK's integration_test package, run on a real device or emulator.
Two practical tips. First, native permission dialogs (Bluetooth, location on Android) can't be tapped by integration_test alone — reach for Patrol, which can drive native UI in CI. Second, you don't always need a physical peripheral: apps like nRF Connect and LightBlue can turn a spare phone into a simulated BLE peripheral with custom services and characteristics, which is perfect for exercising your connect/subscribe path end to end. When something misbehaves on real hardware, our nRF Connect debugging guide shows how to find out whether it's your app or the device.
Keep this layer small. Integration tests are slow and flaky by nature; the goal is coverage of the plugin boundary, not your business logic — that's already covered by fast unit tests.
Common pitfalls and gotchas
- Trying to mock
FlutterBluePlus directly. You can't. It's static. Always wrap it first. Every "how do I mock this" thread ends at the same answer: add an abstraction layer.
- Returning
Stream.value() for connection state. It emits once and completes, so any logic waiting for a second event hangs forever. Use a StreamController you control.
- Forgetting to close controllers. Leaked
StreamControllers cause tests to pass individually but fail when run as a suite. Close them in tearDown.
- Over-mocking. If you find yourself mocking
BluetoothDevice, BluetoothCharacteristic, and BluetoothService in one test, your interface is leaking plugin types. Return your own simple data classes from the BleService boundary instead.
- Testing the wrapper. The
RealBleService wrapper is pure delegation — there's nothing to unit test. Cover it (lightly) in integration tests, not unit tests.
- Registering fallback values late. With mocktail,
any() on custom types needs a registerFallbackValue in setUpAll, or the matcher throws.
Related Guides
FAQ
Can I unit test flutter_blue_plus without a physical device?
Yes — but not by calling the plugin directly. Wrap flutter_blue_plus behind an interface, inject that interface into your app logic, and substitute a fake in tests. Your scanning, connection, and parsing logic then runs entirely in the Dart VM with no hardware.
Should I use mockito or mocktail for BLE tests?
Both work. mocktail is generally the smoother choice today because it requires no code generation or build_runner and is null-safe by default. mockito is fine if your project already relies on it and its generated mocks.
How do I simulate a device disconnecting in a test?
Return a StreamController's stream from your mocked connection-state method, then call controller.add(BluetoothConnectionState.disconnected) at the exact moment you want the drop to happen. This lets you test reconnection logic deterministically.
Do I still need integration tests if my unit tests pass?
Yes, a thin layer of them. Unit tests verify your logic; integration tests (via the integration_test package, plus Patrol for native permission dialogs) verify the plugin actually talks to a real radio and that permissions resolve on-device.
How do I test BLE notifications and indications?
Model the characteristic's value stream as a StreamController in your mock and push byte payloads through it, asserting your parser and UI react correctly. See our guide on notifications vs indications for the behavioral differences you'll want to cover.
Summary
Testing Flutter BLE apps isn't hard once you accept the core constraint: flutter_blue_plus is static, so you have to build your own seam. Wrap the plugin behind an interface, inject that interface, and the entire universe of hardware-dependent scenarios — disconnects, malformed packets, adapter state changes — becomes something you can reproduce on demand in a millisecond-fast test. Save real devices for a thin integration layer that proves the plugin boundary works.