Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Testing without a sensor

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.

Create a fake platform

The fake mixes in MockPlatformInterfaceMixin from plugin_platform_interface, so add that package to the dev dependencies, next to flutter_test:

pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  plugin_platform_interface: ^2.1.8

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:

FieldPurpose
scanControllerEmits the scan results that the test adds.
connectionControllerEmits the connection events that the test adds in place of the native code.
connectCalls, disconnectCallsRecord the address of each connect() and disconnect() call.
requestUrisRecords the URI of each request.
responsesHolds the response for each request URI. The key is the full URI, such as suunto://233830000816/Info.
notificationsHolds the notification bodies for each subscription URI, such as 233830000816/Meas/HR.
unsubscribedRecords 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.

Test a device

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:

test/movesense_device_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:movesense_plus/movesense_plus.dart';

import 'fake_movesense_platform.dart';

const infoJson =
    '{"Content":{"manufacturerName":"Movesense Ltd","brandName":null,"productName":"Movesense",'
    '"variant":"Avocado","design":"black","hwCompatibilityId":"C","serial":"233830000816",'
    '"pcbaSerial":"80413336500828200000100022666H4233","sw":"2.1.5","hw":"H4","additionalVersionInfo":null,'
    '"addressInfo":[{"name":"BLE","address":"0C-8C-DC-1B-23-BF"},{"name":"DFU-BLE","address":"E9-AC-4C-25-9A-65"}],'
    '"apiLevel":"1"}}';

void main() {
  group('MovesenseDevice', () {
    late FakeMovesensePlatform platform;
    late MovesenseDevice device;

    setUp(() {
      platform = FakeMovesensePlatform()
        ..responses['suunto://233830000816/Info'] = const MdsResponse(body: infoJson, statusCode: 200)
        ..responses['suunto://233830000816/System/States/1'] = const MdsResponse(body: '{"Content":0}', statusCode: 200);
      device = MovesenseDevice(
        address: 'AA:BB',
        name: 'Movesense 233830000816',
        movesense: Movesense(platform: platform),
      );
    });

    Future<void> connectDevice() async {
      await device.connect();
      platform.connectionController.add(
        const MovesenseConnectionEvent(type: MovesenseConnectionEventType.connected, address: 'aa:bb', serial: '233830000816'),
      );
      await pumpEventQueue();
    }

    test('connect reports connecting, then connected with device info and battery', () async {
      final statuses = <MovesenseDeviceStatus>[];
      device.statusEvents.listen(statuses.add);

      await device.connect();
      expect(device.status, MovesenseDeviceStatus.connecting);
      expect(platform.connectCalls, ['AA:BB']);

      platform.connectionController.add(
        const MovesenseConnectionEvent(type: MovesenseConnectionEventType.connected, address: 'aa:bb', serial: '233830000816'),
      );
      await pumpEventQueue();

      expect(device.isConnected, isTrue);
      expect(device.serial, '233830000816');
      expect(device.deviceInfo?.productName, 'Movesense');
      expect(device.deviceType, MovesenseDeviceType.hr2);
      expect(device.batteryLevel, MovesenseBatteryLevel.ok);
      expect(statuses, [MovesenseDeviceStatus.connecting, MovesenseDeviceStatus.connected]);
    });

    test('hr stream maps notifications to typed readings', () async {
      await connectDevice();
      platform.notifications['233830000816/Meas/HR'] = ['{"Body":{"average":72.5,"rrData":[833]}}'];

      final reading = await device.hr.first;

      expect(reading.average, 72.5);
      expect(reading.rr, 833);
    });
  });
}

Three details make these tests work:

  • 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.

Simulate failures

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);
});

Test the client

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.

Replace the default platform

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.

Quick links

Page · DocumentationCARP DocumentationPage · DocumentationIntroductionPage · DocumentationQuickstartPage · DocumentationTerminologyPage · DocumentationArchitecture