🔷 Unit Testing CoreBluetooth: Part 2

In Part 1 of this case study, I covered the approach of applying architectural design principles to the layer of my code that was interacting directly with the CoreBluetooth layer, in order to make it fully testable. In this part I will continue where I left off and describe how we can properly test the BluetoothManager class using sophisticated mocks and behavioural assertion.
If you have no idea about what sophisticated mocking and behavioural assertion is, do not worry: All of the design principles used in this case study, are described in detail in the “Unit Testing in Swift” series.

First, let us have a look at the BluetoothManager class that we are testing:

If you are interested in how and why this class came to look like this, I will refer to Part 1 of this article.

Defining what to test

Under normal circumstances (when not writing code for Medium articles) I would suggest starting with defining the test cases for the testable class before actually implementing the code. This is also commonly referred to as Test Driven Development (TDD) and is a great tool to use before implementing any complex piece of code. We will use the same mindset here (even though the class is already implemented) and define the behaviour of the class as a list of testable items. The BluetoothManager should:

  • Assign itself as the delegate of the injected CentralManager on initialisation.
  • Only start scanning for peripherals if the central manager’s state is poweredOn.
  • Only scan for peripherals with the two constant UUIDs.
  • Never attempt to connect to discovered peripherals which have no name.
  • Never attempt to connect to discovered peripherals named “FORBIDDEN-NAME”.
  • Never attempt to connect to discovered peripherals which do not contain manufacturer data.
  • Attempt to Connect to any discovered peripheral with a name (other than the aforementioned) and manufacturer data.

With the list being defined it becomes clear that in order to test any of this, we need to mock the CentralManager and Peripheral types.
If you, at this point, feel like asking me why we would want to mock the two types I can recommend you having a read through the “principles of isolation” section of this article.

Let’s Mock ’n’ Roll!

Before you continue reading through this section I suggest that you read “Unit Testing in Swift: Mocking” and “Unit Testing in Swift: Behavioural Assertion” in order to have a clear understanding of mocking in general and to get an introduction to the principles of the base Mock class used in this case study.

With that being said, let us start by mocking the CentralManager type.
From our list of testable items we can defer that in order to test if the connection or scanning for a peripheral has been executed, we must know wether or not the corresponding functions have been called on the CentralManager. This is where our Mock base class comes to the rescue as it already provides a logging mechanism for the function calls:

The log(parameters:) call in both functions, makes sure that any call to the respective function will be logged with the corresponding parameters. The generic nature of the base Mock class requires us to implement a specific function type for our CentralManagerMock. This is easily done by adding the Functions enum which in turn allows us to easily access the function calls afterwards in our unit tests.
I would also like to point out that the Parameters struct is simply just a pattern I tend to use for easier readability. When we later fetch the parameters for the function calls, in the unit tests, we will be able to handle them easily with type-aliases for the parameters instead of working with tuples.

Our Peripheral type is much less complex and we will not be needing to subclass Mock for this:

With the mocks being in place, it is time to start unit testing.

Writing the test cases

There are many ways to write unit tests and a variety of different frameworks to help us write them, however I prefer to go all basic with XCTests, hence this article will also be based on this.

Let us start by defining our test case class for the BluetoothManager:

What you see above is simply just the base setup of an XCTestCase for the BluetoothManager. As well as there are many ways to approach testing, there are also many ways manage mocks. In this case I am using a private Mocks struct to keep track of them. In the setUp() function I make sure the mocks and manager class are initialised and ready to be tested by each following test case.
I generally always start by testing that the setup works as expected —remember that our goal is to be as much in control of the test scenarios as possible, so by making sure that our starting point is valid, we also ensure the validity of our tests. So let us start by simply testing the initial state. We would expect the testable object to not be nil, but we would also expect the delegate property of the CentralManager to be set already during initialisation. The ladder corresponds to the testable item that we identified in the beginning of this article:

  • Assign itself as the delegate of the injected CentralManager on initialisation.

In terms of behaviour we do not expect anything else and hence it is safe to assume that the CentralManager should not have received any function calls during the initialisation of the BluetoothManager. This can be expressed with three assertions:

Next, let us test the behaviour of the centralManagerDidUpdateState function.
The behaviour of this function is covered by the two next items in our testable items list:

  • Only start scanning for peripherals if the central manager’s state is poweredOn.
  • Only scan for peripherals with the two constant UUIDs.

The first item has a relation to the state enum of the CentralManager, injected into the function. In cases like these I always suggest to create a test function for each of the possible cases for the enum — even if most of the cases are going to be completely identical. In our case the state may take 6 different values and we would expect the same behaviour for 5 of the cases and another behaviour for the .poweredOn case. By creating a test for each of the cases we ensure that if we may treat another case differently in the future, that case is easily identified and testable in its own test function. The tests for the 6 different state cases look as follows:

As can be seen, for the cases .unknown, .unsupported, .unauthorized, .resetting and .poweredOff we test that the centralManager mock did not receive any calls. Without behavioural assertion, this would not be possible to test.
For the positive case .poweredOn, we check that the centralManager did receive the correct function call and furthermore we are able to validate that the given UUIDs were consistent with the expectation from our testable items list. This is where the true power of more advanced mocking comes into play and really shows its strong side.

The remaining 4 testable items are all related to the didDiscoverPeripheral function:

  • Never attempt to connect to discovered peripherals which have no name.
  • Never attempt to connect to discovered peripherals named “FORBIDDEN-NAME”.
  • Never attempt to connect to discovered peripherals which do not contain manufacturer data.
  • Attempt to Connect to any discovered peripheral with a name (other than the aforementioned) and manufacturer data.

The 3 first of them being negative scenarios while the last one is a positive scenario where all the conditions are met. In the same manner as before we split our tests up into the potential scenarios that we may experience during runtime. This time, starting with the positive scenario we again see the power of behavioural assertion and how the facade design pattern allows us to be in complete control of the unit test case scenarios.

In this case there are a few different combinations for the positive scenario as well as the negative one. In a real implementation I would make sure to test all of the scenarios, but for the sake of this case study I have decided only to include a few of them.

Et Voila!

That is it! We have now fully tested the BluetoothManager class and displayed how the application of architectural design principles as well as testing for behaviour rather than pure logic statements, can improve the quality of our unit tests. More information on the design principles and unit testing in general can be found in the “Unit Testing in Swift” series.

If you liked this article feel free to let me know by giving it a clap or two!

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.

--

--

--

Lead iOS Engineer @ Jabra 🇩🇰 Full time 💻📲 — part time 🚴🏽‍⛷🏃🏽‍🏊🏼‍

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Using SwiftUI in production (Part 1)

Wire for web, 2020–07–24

Present SFSafariViewController | Xcode 12, Swift 5.3

SwiftUI Tutorial: State and Binding

OOPS with Swift

Optionals and Unwrapping optionals in swift

SwiftUI — Data transfer between Structs

Display GitHub Jobs in UITableView + Search

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Kewin

Kewin

Lead iOS Engineer @ Jabra 🇩🇰 Full time 💻📲 — part time 🚴🏽‍⛷🏃🏽‍🏊🏼‍

More from Medium

Preventing Memory Leaks Using XCTests

Swift: Tracking memory leaks in tests

Retriable API Calls with Modern Swift

Retriable API Calls with Modern Swift

Handling Concurrency with Async Await in Swift