Test code that uses movesense_plus against a fake platform that replays scripted responses.
Copy MarkdownOpen inOpen in GitHubView as MarkdownOpen in Scira AIOpen in ChatGPTOpen in ClaudeOpen in Cursor
Unit tests can run code that uses movesense_plus on the development machine, without a sensor or a phone. Movesense and MovesenseDevice reach the native code only through a MovesensePlatform, so a test can give them a fake platform instead.
The fake records the connect(), disconnect(), request() and unsubscribe() calls, replays scripted responses and notifications, and emits the events that the test adds. The fake and the tests in Test a device and Test the client come from the plugin's own movesense_plus_test.dart.
Copy the fake into test/fake_movesense_platform.dart:
test/fake_movesense_platform.dart
import 'dart:async';import 'package:movesense_plus/movesense_plus.dart';import 'package:movesense_plus/movesense_plus_platform_interface.dart';import 'package:plugin_platform_interface/plugin_platform_interface.dart';/// Scripted platform: records commands and replays canned responses and notifications.class FakeMovesensePlatform extends MovesensePlatform with MockPlatformInterfaceMixin { final scanController = StreamController<MovesenseScanResult>.broadcast(); final connectionController = StreamController<MovesenseConnectionEvent>.broadcast(); final connectCalls = <String>[]; final disconnectCalls = <String>[]; final requestUris = <String>[]; final responses = <String, MdsResponse>{}; final notifications = <String, List<String>>{}; final unsubscribed = <int>[]; int _nextSubscriptionId = 0; @override Stream<MovesenseScanResult> get scanResults => scanController.stream; @override Stream<MovesenseConnectionEvent> get connectionEvents => connectionController.stream; @override Future<void> startScan() async {} @override Future<void> stopScan() async {} @override Future<void> connect(String address) async => connectCalls.add(address); @override Future<void> disconnect(String address) async => disconnectCalls.add(address); @override Future<MdsResponse> request(MdsRequestMethod method, String uri, String contract) async { requestUris.add(uri); final response = responses[uri]; if (response == null) throw MdsError(404, 'No response scripted for $uri'); return response; } @override int subscribe( String uri, String contract, { MdsSuccessCallback? onSubscribe, MdsErrorCallback? onSubscribeError, required MdsNotificationCallback onNotification, MdsErrorCallback? onSubscriptionError, }) { final id = ++_nextSubscriptionId; scheduleMicrotask(() { onSubscribe?.call(const MdsResponse(body: '', statusCode: 200)); for (final body in notifications[uri] ?? const <String>[]) { onNotification(body); } }); return id; } @override Future<void> unsubscribe(int subscriptionId) async => unsubscribed.add(subscriptionId);}
A test drives the fake through its fields:
Field
Purpose
scanController
Emits the scan results that the test adds.
connectionController
Emits the connection events that the test adds in place of the native code.
connectCalls, disconnectCalls
Record the address of each connect() and disconnect() call.
requestUris
Records the URI of each request.
responses
Holds the response for each request URI. The key is the full URI, such as suunto://233830000816/Info.
notifications
Holds the notification bodies for each subscription URI, such as 233830000816/Meas/HR.
unsubscribed
Records the ID of each subscription that ends.
startScan() and stopScan() do nothing. A request fails with an MdsError with status 404 when responses has no entry for its URI. Right after a subscription starts, the fake acknowledges it and delivers the notifications for its URI, so script them before the code under test subscribes.
To test code that works with a MovesenseDevice, pass movesense: Movesense(platform: platform) to its constructor. The test then plays the native side. These tests from the plugin connect a device and read its heart rate:
Scripted requests: when a device connects, it requests /Info and /System/States/1 from the sensor. setUp() scripts both responses for the serial 233830000816. infoJson has the hardware type H4, which the plugin reports as MovesenseDeviceType.hr2.
Connection event:connectDevice() adds the connected event that the native code sends with the serial. pumpEventQueue() then waits until the device has read both responses and reports connected. The device compares addresses without regard to case, so aa:bb matches AA:BB.
Notifications: the hr stream subscribes to <serial>/Meas/HR. The second test scripts 233830000816/Meas/HR before it reads device.hr.
To test how your code handles a lost connection, add a disconnected event. The event may carry only the serial, as in this test from the plugin, which belongs in the MovesenseDevice group:
test('handles a disconnect event that carries only the serial', () async { await connectDevice(); platform.connectionController.add( const MovesenseConnectionEvent(type: MovesenseConnectionEventType.disconnected, serial: '233830000816'), ); await pumpEventQueue(); expect(device.status, MovesenseDeviceStatus.disconnected); expect(device.serial, isNull); expect(device.batteryLevel, MovesenseBatteryLevel.unknown);});
To test an error path, leave a response out. getState(), for example, returns null when its request fails:
test('getState returns null when the component is unsupported', () async { await connectDevice(); expect(await device.getState(MovesenseSystemState.freeFall), isNull);});
To test scanning code, create the client with Movesense(platform: platform) and add a MovesenseScanResult with a name and an address to scanController. scanResults wraps it in a MovesenseDevice. These tests from the plugin read a scan result and a notification:
test/movesense_client_test.dart
import 'package:flutter_test/flutter_test.dart';import 'package:movesense_plus/movesense_plus.dart';import 'fake_movesense_platform.dart';void main() { group('Movesense', () { late FakeMovesensePlatform platform; late Movesense movesense; setUp(() { platform = FakeMovesensePlatform(); movesense = Movesense(platform: platform); }); test('scanResults wraps scan results in devices bound to the client', () async { final devices = movesense.scanResults.take(1).toList(); platform.scanController.add(const MovesenseScanResult(name: 'Movesense 123', address: 'AA:BB')); final device = (await devices).single; expect(device.name, 'Movesense 123'); expect(device.address, 'AA:BB'); expect(device.status, MovesenseDeviceStatus.disconnected); }); test('subscribe emits notification bodies and unsubscribes on cancel', () async { platform.notifications['123/Meas/Acc/13'] = ['{"Body":{"Timestamp":1}}']; final bodies = await movesense.subscribe('123/Meas/Acc/13').take(1).toList(); expect(bodies, ['{"Body":{"Timestamp":1}}']); expect(platform.unsubscribed, [1]); }); });}
The scan test listens before it adds the result: scanController is a broadcast controller, so it drops results that arrive while nothing listens. In the subscription test, take(1) cancels the stream after the first notification, so the client unsubscribes and the fake records the subscription ID 1.
Code that creates Movesense() without a platform, or MovesenseDevice() without a movesense client, uses MovesensePlatform.instance. So do the static Mds and MdsAsync APIs.
By default, the instance is a PigeonMovesense, which talks to the native code over platform channels. In a unit test, it fails:
When it is created, it registers handlers for the events that the native code sends. That needs a Flutter binding, so in a test without one, Movesense() throws a FlutterError: "Binding has not yet been initialized."
With a binding from TestWidgetsFlutterBinding.ensureInitialized(), no native code answers. startScan() and connect() fail with a PlatformException with the code channel-error, and requests and subscriptions fail with an MdsError with status 0.
Set the instance to the fake before the code under test runs:
test/default_platform_test.dart
import 'package:flutter_test/flutter_test.dart';import 'package:movesense_plus/movesense_plus.dart';import 'package:movesense_plus/movesense_plus_platform_interface.dart';import 'fake_movesense_platform.dart';void main() { late FakeMovesensePlatform platform; setUpAll(() { platform = FakeMovesensePlatform(); MovesensePlatform.instance = platform; }); test('MdsAsync.get returns the scripted content', () async { platform.responses['suunto://233830000816/Info'] = const MdsResponse( body: '{"Content":{"productName":"Movesense"}}', statusCode: 200, ); final info = await MdsAsync.get(Mds.createRequestUri('233830000816', '/Info'), '{}'); expect(info['productName'], 'Movesense'); }); test('a device without a client connects through the fake', () async { final device = MovesenseDevice(address: '0C:8C:DC:1B:23:BF'); await device.connect(); expect(platform.connectCalls, ['0C:8C:DC:1B:23:BF']); });}
When you set the instance matters:
Movesense() and MovesenseDevice() read the instance when they are created and keep that platform. Set the instance before the code under test creates them.
Most Mds and MdsAsync methods go through a client that is created at their first call and kept until the test file ends. Set the instance in setUpAll() and share one fake between the tests. With a new fake in every setUp(), their requests in later tests still go to the first fake.
Mds.subscribe() and Mds.unsubscribe() read MovesensePlatform.instance at every call.
The MovesensePlatform.instance setter accepts any class that extends MovesensePlatform. A mock that implements the interface instead, such as one from mockito, must also mix in MockPlatformInterfaceMixin. Without the mixin, the setter fails with an AssertionError.