🔷 Unit Testing CoreBluetooth: Part 1
A case study of how I approach unit testing of a CoreBluetooth layer in an iOS application.
When it comes to unit testing the part of your code that interacts directly with the Swift libraries, multiple challenges occur: Library objects are usually final with unavailable initializers. Additionally, Swift library delegate functions do not easily allow replacing the objects with mocked versions. These challenges can, however, be overcome by implementing the principles of proper architecture that I have described in the “Unit Testing In Swift” series.
If you are curious about unit testing in Swift and this is the first time you come across my articles, I would suggest that you start with the introduction to the series.
In this article, I will show how these principles can be applied to a layer of code interacting directly with the
CoreBluetooth stack, in order to allow easy unit testing. If you are more interested in the actual implementation of the unit tests rather than the principles that allow for good unit testing, feel free to jump directly to Part 2 of this case study.
Defining the problem
Let us begin by considering the code in the gist below.
What you see is a very simple
BluetoothManager class that contains an instance of the
CBCentralManager, used to discover and connect to Bluetooth peripherals.
In order to do so, the
BluetoothManager must implement the
CBCentralManagerDelegate protocol. When we take a closer look at the two implemented delegate functions, the problem becomes clear: Both functions contain vital logic that should be tested, however as we are not able to mock and initialise the
CBPeripheral instances, we are not able to properly test the functions in a controlled environment. Furthermore, the private
CBCentralManager instance has its
delegate property set in the initialiser of the
BluetoothManager class. This is a vital line of code in order for the application to work and we would benefit a lot from unit testing it, however this is also not possible with the current implementation.
In the following sections we will take a closer look at how we can improve the code to make it completely testable.
It’s time to build a Façade!
As we have now made it clear that the problem exists due to the fact that the
CBPeripheral classes can not be mocked, our goal must be to make it possible to mock them. This can be done by using the isolation principles of the façade and dependency injection design patterns that are further described in depth in this article. In other words, we need to define and utilise our own protocols instead of concrete types from the internal library (in this case the central manager and the peripheral).
Implementing the façade
So how do we actually do this? The approach is to clarify which properties and functions are accessed on the types that we wish to make a protocol for. In the case of the
CBCentralManager we first access the
stateproperties, followed by a call to the
scanForPeripherals(withServices:options:) function and finally a call to
connect(_:options:) is made.
In the case of the
CBPeripheral we only access its
With this knowledge we can define and implement our protocols:
Notice how the
CBPeripheral classes both conform to their respective protocols almost without requiring additional implementations. The
connect(_:options:) function has to be implemented because a cast from
CBPeripheral is needed. Throwing a
fatalError in this case is safe, as you would only ever expect the real
CBCentralManager to discover
However we are not finished yet: Simply changing the
CBPeripheral types to
Peripheral in the
BluetoothManager class would cause compile time errors as the
CBCentralManagerDelegate protocol would not be respected. Because of this, we have to make a facade for the
CBCentralManagerDelegate protocol. Wait, what? A facade of a facade? Yes, you heard me!
Notice how the
CentralManagerDelegate extends the
CBCentralManagerDelegate functions and uses a simple cast in order to hide the
CoreBluetooth complexity. While this would be ideal, this is unfortunately not possible because the
CBCentralManagerDelegate has an objective-c exposure requirement. Concrete implementations within protocol extensions can not be translated into objective-c and thus we would get another compile time error. However, I decided to include this in the article because this is indeed very possible for internal Swift libraries which do not have the objective-c exposure requirement.
With our facades being defined, we can now improve the
BluetoothManager implementation by conforming to the
CentralManagerDelegate protocol instead of the
In a perfect world we would not need to implement the old
CBCentralManagerDelegate functions (the ones below the
MARK), however as described above, the bridging between objective-c and swift requires us to do this. The sharp developer would quickly realise that the
CentralManagerDelegate protocol is in fact not needed at all, however for the cases where the objective-c exposure is not required I would strongly suggest that this intermediate protocol is implemented.
With this, the main goal has been achieved: The two delegate functions can now be completely controlled. At this point we are able to fully test the functionality of the two functions, but the
BluetoothManager class is still not fully testable.
Injecting the dependencies
In order to test the
delegate property being set (as described in the beginning of the article), we need to utilise dependency injection. Simply injecting the
CentralManager into the initialiser of the
BluetoothManager helps us solve this issue:
It is now very simple to test the
delegate property being set as well as the delegate function implementations and their respective behaviour and logic.
Exactly how to approach these unit tests, implement sophisticated mocks for our new protocols and asserting the behaviour of the functions, will be covered in the next and final part of this case study.
As always, if you have any questions or comments, feel free to reach out to me by commenting on these articles. I will reply to all messages.