Description
Problem
A massive benefit of this library is that it unlocks the ability to write unit tests that accurately simulate a real-world Bluetooth-enabled device. However there are a couple of inconveniences with the current implementation:
-
The supervision timeout determines when an out-of-range device should report as disconnected. It is hard coded to
4.0
seconds - any unit test that simulates a proximity change to.outOfRange
must wait a full four seconds to pass successfully. -
The RSSI reports with a random deviation making it impossible to check against a specific, expected RSSI. Whilst you can have a unit test that checks within a specific range, that has its own issues:
- It breaks encapsulation by requiring the client code to know the private +/- 15 dBm variation
- The signal-strength cannot be included in any form of reliable equatability when comparing a
CBPeripheral
to an expected state during a test.
Example
The following test will fail on line 100
(RSSI value not guaranteed to be -40
) and line 79
(would exceed timeout)
import XCTest
import CoreBluetoothMock
@testable import BluetoothMockExample
class BluetoothDelegateTests: XCTestCase {
var bluetoothManager: CBMCentralManager!
var poweredOnExpectation: XCTestExpectation!
var discoveryExpectation: XCTestExpectation!
var connectionExpectation: XCTestExpectation!
var disconnectExpectation: XCTestExpectation!
var discoveredPeripherals: [CBMPeripheral] = []
var peripheralSpec: CBMPeripheralSpec!
override func setUp() {
super.setUp()
peripheralSpec = CBMPeripheralSpec
.simulatePeripheral(proximity: CBMProximity.near)
.advertising(
advertisementData: [
CBMAdvertisementDataLocalNameKey: "Test_Device",
CBMAdvertisementDataServiceUUIDsKey: CBMUUID(),
CBMAdvertisementDataIsConnectable: true as NSNumber
],
withInterval: 0.000001
)
.connectable(
name: "Test_Device",
services: [
CBMServiceMock(
type: CBMUUID(),
primary: true,
characteristics: [CBMCharacteristicMock(type: CBMUUID(), properties: [.read])]
)
],
delegate: self
)
.build()
CBMCentralManagerMock.simulatePeripherals([peripheralSpec])
CBMCentralManagerMock.simulateAuthorization(.allowedAlways)
bluetoothManager = CBMCentralManagerFactory.instance(delegate: self, queue: .main)
}
override func tearDown() {
CBMCentralManagerMock.tearDownSimulation()
bluetoothManager = nil
discoveredPeripherals.removeAll()
super.tearDown()
}
func testPeripheralConnectThenOutOfRange() {
poweredOnExpectation = expectation(description: "Bluetooth powered on")
discoveryExpectation = expectation(description: "Peripheral discovered")
connectionExpectation = expectation(description: "Peripheral connected")
disconnectExpectation = expectation(description: "Peripheral disconnected")
CBMCentralManagerMock.simulatePowerOn()
wait(for: [poweredOnExpectation], timeout: 1.0)
bluetoothManager.scanForPeripherals(withServices: nil)
wait(for: [discoveryExpectation], timeout: 1.0)
XCTAssertEqual(discoveredPeripherals.count, 1)
XCTAssertEqual(discoveredPeripherals.first?.name, "Test_Device")
guard let peripheral = discoveredPeripherals.first else {
XCTFail("No peripheral discovered")
return
}
bluetoothManager.connect(peripheral)
wait(for: [connectionExpectation], timeout: 1.0)
peripheralSpec.simulateProximityChange(.outOfRange)
wait(for: [disconnectExpectation], timeout: 1.0) // FAIL: 4 seconds needed
XCTAssertEqual(discoveredPeripherals.count, 0)
}
}
extension BluetoothDelegateTests: CBMPeripheralSpecDelegate {
func peripheralDidReceiveConnectionRequest(_ peripheral: CBMPeripheralSpec) -> Result<Void, Error> {
return .success(())
}
}
extension BluetoothDelegateTests: CBMCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CoreBluetoothMock.CBMCentralManager) {
if central.state == .poweredOn {
poweredOnExpectation.fulfill()
}
}
func centralManager(_ central: CBMCentralManager, didDiscover peripheral: any CBMPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
discoveredPeripherals.append(peripheral)
XCTAssertEqual(RSSI.intValue, -40) // FAIL: value not guaranteed to be -40
discoveryExpectation.fulfill()
}
func centralManager(_ central: CBMCentralManager, didConnect peripheral: any CBMPeripheral) {
connectionExpectation.fulfill()
}
func centralManager(_ central: CBMCentralManager, didDisconnectPeripheral peripheral: any CBMPeripheral, error: (any Error)?) {
discoveredPeripherals.removeAll(where: { $0.identifier == peripheral.identifier })
disconnectExpectation.fulfill()
}
}
See test failures in Xcode:

Proposed Solution
- Add
supervisionTimeout
to theCBPeripheralSpec
with a default value of4.0
seconds. - Make
rssiDeviation
public and mutable using an enumeration to limit possible values.