Mocking CoreBluetooth

CoreBluetooth is the native iOS framework to communicate with Bluetooth-enabled Low Power (LE) devices, except iBeacons (which require CoreLocation) and HomeKit devices. It is easy to start with, if one is familiar with Bluetooth LE and GATT. However, physical devices are required on both ends, as the simulator does not support Bluetooth technology. This requirement causes 2 problems. Bluetooth-enabled apps are difficult to test, due to the need of physical devices. Apple explicitly forbids extending and altering the default behavior of CoreBluetooth classes:

Don’t subclass any of the classes of the Core Bluetooth framework. Overriding these classes isn’t supported and results in undefined behavior.

The second issue is with taking screenshots. AppStore requires screenshots taken on the largest and most expensive phones and tables, so unless you have them this task may cause you lots of troubles, like it did for us. Besides, common frameworks, like fastlane, are usually used on a simulator, where screenshots are taken during the process of automatic testing. This works fine for most apps, but with Bluetooth, an additional layer that separates the Bluetooth API from the app is required to provide mock devices.

Core Bluetooth Mock

With this in mind, we created the CoreBluetoothMock framework. The library is very easy to add even to large applications, requires almost no code refactoring and provides a simple API for mocking peripherals.

Limitations

There are some minor limitations, that may be solved in the future. The current version, 0.7.0, supports only the central manager. Connection events, L2CAP channels, connection options and ANCS features are not supported. Please check the release log here for the current set of features. Also, we only support Swift. The library hasn’t been tested in a project written in Objective-C.

How to start

If you can live with the limitations, all you need to do, is:

  1. Add the library to your project (CocoaPods, Carthage or Swift Package Manager),
  2. Copy CoreBluetoothTypeAliases.swift to your project,
  3. Remove import CoreBluetooth from all classes that use Bluetooth.
  4. CBCentralManager must be created using CBCentralManagerFactory, instead of a simple init.

With those steps completed, the app should work exactly like before. All the calls to CBCentralManager and CBPeripheral will be forwarded to their native counterparts, and native callbacks will be translated to mock callbacks. At this point, we recommend testing that the app behaves normally.

When you launch your app in a simulator, it will behave as if the Bluetooth adapter was turned off, not unsupported. The CBCentralManager.state will return .poweredOff, instead of .unsupported like it used to. This is because of 2 reasons: the mock implementation has replaced the native one, and the default state of the manager is disabled. You may easily modify the initial state using

CBMCentralManagerMock.simulateInitialState(.poweredOn)

When working with CoreBluetoothMock you'll stumble upon number of methods and properties named with simulate prefix. They all can be used to mock behavior of the central manager or peripheral.

Mocking peripherals

The most important feature of this mocking framework is the ability to mock Bluetooth LE peripherals. Before you create a central manager instance, provide list of peripheral specifications using

CBMCentralManagerMock.simulatePeripherals(_ peripherals: [CBMPeripheralSpec])

This method may only be called once, and it should set all peripherals you need to mock. Using the CBMPeripheralSpec.Builder you may define the peripheral's proximity (also "out of range"), advertising data, whether it is connectable with list of services, or perhaps already connected using some other application. For example, this is the implementation of a device advertising with Nordic LED Button service, called "blinky":

let blinky = CBMPeripheralSpec
    .simulatePeripheral(proximity: .immediate)
    .advertising(
        advertisementData: [
            CBAdvertisementDataLocalNameKey    : "nRF Blinky",
            CBAdvertisementDataServiceUUIDsKey : [CBUUID.nordicBlinkyService],
            CBAdvertisementDataIsConnectable   : true as NSNumber
        ],
        withInterval: 0.250,
        alsoWhenConnected: false)
    .connectable(
        name: "nRF Blinky",
        services: [.blinkySerivce],
        delegate: BlinkyCBMPeripheralSpecDelegate(),
        connectionInterval: 0.150,
        mtu: 23)
    .build()

The services are defined like this:

extension CBUUID {
    static let nordicBlinkyService  = CBUUID(string: "00001523-1212-EFDE-1523-785FEABCD123")
    static let buttonCharacteristic = CBUUID(string: "00001524-1212-EFDE-1523-785FEABCD123")
    static let ledCharacteristic    = CBUUID(string: "00001525-1212-EFDE-1523-785FEABCD123")
}

extension CBMCharacteristicMock {
    
    static let buttonCharacteristic = CBMCharacteristicMock(
        type: .buttonCharacteristic,
        properties: [.notify, .read],
        descriptors: CBMClientCharacteristicConfigurationDescriptorMock()
    )

    static let ledCharacteristic = CBMCharacteristicMock(
        type: .ledCharacteristic,
        properties: [.write, .read]
    )
    
}

extension CBMServiceMock {

    static let blinkySerivce = CBMServiceMock(
        type: .nordicBlinkyService, primary: true,
        characteristics:
            .buttonCharacteristic,
            .ledCharacteristic
    )
    
}

The CBMPeripheralSpecDelegate class is where you implement the behavior of the mock peripheral. This protocol contains number of methods that will be called whenever a request has been made to this peripheral, for example a value was read or written. The delegate should emulate the same behavior as the real peripheral. By default, all the methods are implemented and return default values so make sure you implement the ones you require. For example, the blinky delegate implementation looks like that:

private class BlinkyCBMPeripheralSpecDelegate: CBMPeripheralSpecDelegate {
    private var ledEnabled: Bool = false
    private var buttonPressed: Bool = false
    
    private var ledData: Data {
        return ledEnabled ? Data([0x01]) : Data([0x00])
    }
    
    private var buttonData: Data {
        return buttonPressed ? Data([0x01]) : Data([0x00])
    }
    
    func peripheral(_ peripheral: CBMPeripheralSpec,
                    didReceiveReadRequestFor characteristic: CBMCharacteristic)
            -> Result<Data, Error> {
        if characteristic.uuid == .ledCharacteristic {
            return .success(ledData)
        } else {
            return .success(buttonData)
        }
    }
    
    func peripheral(_ peripheral: CBMPeripheralSpec,
                    didReceiveWriteRequestFor characteristic: CBMCharacteristic,
                    data: Data) -> Result<Void, Error> {
        if data.count > 0 {
            ledEnabled = data[0] != 0x00
        }
        return .success(())
    }
}

You may emulate sending notifications/indications from your mock peripheral by calling

blinky.simulateValueUpdate(buttonData, for: .buttonCharacteristic)

Keep in mind, that the CoreBluetoothMock framework tries to emulate the behavior of peripherals as precisely as possible. For example, writing and reading a value takes a connection interval delay, or more if the data is longer and long write or long read procedures were required. To make it faster, set the interval to 0 when defining the peripheral specification.

Mocking disconnections

The framework also allows to mock different types of disconnections. Call

CBMCentralManagerMock.simulatePowerOff()

to simulate turning Bluetooth off on the iPhone. You will not get centralManager(_:didDisconnectPeripheral:error:) callback, just like you don't get it when working with native API.

To simulate a disconnection initiated from the peripheral side, call

blinky.simulateDisconnection() // gentle disconnection

blinky.simulateDisconnection(withError error: Error) // use CBError or CBATTError

blinky.simulateReset() // resetting device (timeout)

The disconnection due to a timeout will be reported to the app after 4 seconds (default supervision timeout).

Example

Before you start, we recommend checking our nRF Blinky sample app: https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock -> Example. You may use "pod try CoreBluetoothMock" command to automatically download the app using CocoaPods. Unless you modify the AppDelegate, the mock implementation will only be enabled when UI Tests are started.

Final word

We hope some of you find our framework helpful. If you have feature requests, suggestions, or, most importantly, bug fixes, please use the Issue tracker on the GitHub project.

Anonymous