TDD with CoreBluetooth
Unit-testing is an essential part of day-to-day development since it reduces cost of fixing production bugs in the future. If you ever had to deal with 3rd party libraries or Apple frameworks in your project, it isn’t trivial to unit-test components that collaborate with those framework types.
TL; DR
- Expose obj-c runtime into Swift (if possible)
- Use delegate of that type to invoke the appropriate method
- Assert the expected outcome
Challenges that frameworks pose
If you ever had to test-drive your implementation that relies on frameworks, you often find yourself caught off guard as you can’t mock/stub components from those frameworks. It can’t be done mostly since initializers of those components are internal and thus they can’t be created from outside.
For instance, if you ever try to create an instance of CBPeripheral, you simply can’t make that work as the compiler would be angry at you :)
Interesting fact
Underlying implementation of CoreBluetooth is written in Objective-C and we can do some magic with it so as to bypass the problem of internal initializers.
TDD with CoreBluetooth
Let’s start by test-driving the entire implementation of BluetoothManager. This component is a proxy that helps us communicate with CoreBluetooth API without the need to carry on importing the framework in the entire project.
Writing our first test:
In this article, we would follow TDD and write the test first and then the production code.
We create an instance of BluetoothManager (although this type does not exist yet) and then assert that delegate is not nil. Remember that we have to write minimum amount of code to make the test pass. Let’s satisfy the compiler and set our manager as a delegate.
Nothing fancy going on as we create our BluetoothManager and inherit from NSObject (since CB is obj-c under the hood). We hold a strong reference to CBCentralManager and set ourselves as a delegate of CBCentralManagerDelegate.
However, we have just tested that we have correctly initialized instance of BluetoothManager and that the delegate was set. We can do more and test-drive all delegate methods (for simplicity we will cover a few).
We have a first required method of CBCentralManagerDelegate.
func centralManagerDidUpdateState(_ central: CBCentralManager)
This method is invoked by Apple whenever our application starts app. Since Apple takes the entire heavy loading of it in a real instance of our app, we can take a lead and test this method by simply calling it in our test.
test_init_triggersDidUpdateStateDelegateMethod does exactly prove that the method was invoked. We create our sut or system under test and listen for a callback that would be triggered whenever centralManagerDidUpdateState is called. For triggering our callback, we would simply invoke a delegate method in our test as it can be seen from the gist. Finally, as the didUpdateState closure was invoked, we increment the callCount and assert that it indeed was incremented and equals 1.
We haven’t run into a problem with unavailable initializers yet by testing the didUpdateState method since it does not have any types that cannot be instantiated during tests.
Let’s test another method that discovers nearby BLE device (after scanning).
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber)
CBPeripheral can’t be created and is always instantiated by CoreBluetooth at runtime. How can we test drive this method?
Magic with Objective-C
Underlying CoreBluetooth types were written in obj-c and it we can do a trick so as to test-drive anything that has an obj-c interface.
Creator is a type that has a class method create for creating Any! type in Swift via class name. Objective-C allows us to create an instance via its’ class name.
So as to expose an obj-c type into Swift, we have to add a Creator.h header file inside ModuleName-Bridging-Header.h.
Now, we are able to create any type from obj-c runtime inside our Swift files/classes. How cool it is!
create method retrieves Any! type that we can then downcast to the type required. Let’s write one more test for pairing.
Setup is a bit different in comparison to other tests as we have to instantiate a peripheral beforehand so as to check it in the assertion. The idea is to create an instance via Creator and then downcast it to CBPeripheral. It can be noted that we are instantiating an instance via string class name “CBPeripheral”. Since casting might fail, we are asserting that in tests as well. Moreover, we have to add a peripheral as an observer to the delegate as a test would be failing otherwise. Finally, we invoke a scan instance method on our sut and wait for a peripheral to be passed in back in a callback. Since this operation is asynchronous, we have to wait for an expectation to be fulfilled.
Inside scan, we capture the closure and invoke it later on inside a delegate method didDiscover peripheral. As we create a CBPeripheral inside our test, it is obvious that we are receiving it inside a delegate method and then asserting it in tests.
We have test-driven the entire implementation of our BluetoothManager that relies on CoreBluetooth types.
Summary
CoreBluetooth can and should be tested in your app as it might be hard to debug manually. Tests prove correctness of our code now, 1 week or 1 year later after it has been written. Simply do a trick by exposing obj-c runtime into Swift via Creator and instantiate whatsoever object you are interested in via its class name.
Sources
Source code is publicly available at: https://github.com/babaev-islom/Testing-CB