The Value of Unit Tests and Testing Code with Non-Mockable Dependencies
By Rob Hartman | Principal Software Engineer |
In a previous post about the value of writing unit tests, I mentioned that in mobile work, Swift and Dart/Flutter can present unique challenges when creating mocks. The lack of a mocking framework in Swift forces you to create your own mocks, and this means that everything that must be mocked must be a Swift protocol or a class whose properties and methods can be overridden: class or static methods simply can’t be mocked directly. Mocking frameworks used in Dart and Flutter also do not provide a way to mock static methods.
In the mobile development we do at SpinDance, we often interact with Bluetooth Low Energy (BLE) devices. It’s common to reach for 3rd party libraries, but many of them utilize design patterns that make them hard to mock, for example:
– FlutterBluePlus: This Flutter package’s API utilizes static methods.
– SwiftyBluetooth: This Swift package’s API utilizes top-level functions and the singleton pattern to provide a closure-based wrapper around iOS Core Bluetooth. (Side note: In a project from a few years back, we wrapped the SwiftyBluetooth API in a Swift Combine-based API.)
– Espressif Provisioning iOS: We use the singleton-based Espressif mobile Wi-Fi provisioning libraries a lot.
Any code that depends directly upon libraries such as this becomes impossible to unit test. The tendency then is to simply skip testing such files. This is less than ideal!
When working with third-party libraries, it is almost always useful to “wrap” the library with some kind of abstraction and minimize the number of files that interact with or are dependent upon the library. This has numerous benefits, including making your code that uses libraries such as the ones listed above more testable, so let’s take a look at a simple example in Swift.
Recall the classic cartoon, Road Runner. Suppose Road Runner is getting old and can’t rely on raw speed to stay ahead of Wile E. Coyote, his ever-determined nemesis, as much as he could back in the day. He needs some tech to help him out. Meanwhile, ACME corporation, Coyote’s perennial supplier who has changed with the times, now ships all their explosive devices with their latest proprietary secure communication protocol built on BLE, and like any good hardware manufacturer, they’ve developed an open-source mobile SDK that handles all the complexities of interacting with their devices.
Road Runner is smart and sort of a self-starter. He wants to build his own app around the ACME Mobile SDK that will help him detect nearby ACME devices, and then blow them up from a safe distance (within BLE range of course, and from behind a large rock). He’s also older now and knows he wants as much unit test coverage on his code as he can get. Coyote is stuck in the past and still uses the plunger-and-wire method of firing his explosives. Road Runner is confident the devices will be just sitting there, ripe to be claimed by his app.
The ACME library was developed, you guessed it, using the singleton pattern, and they didn’t really use good names in their code:
public class AcmeThingController { /// It's a singleton! static let shared = AcmeThingController() private init() {} // The ACME developers decided to conceal the inner workings of // their devices and use a private type AcmeThing to represent // them. This makes our job in this example a little easier, later // on, since we don't have to create an abstraction around AcmeThing. private var things = [AcmeThing]() public func scanForAcmeThings() -> [String] { // Scan and initialize the private cache of things, and return // the IDs. // things = ... return things.map { $0.acmeThingIdentifier } } // ACME's motto is "No regrets, Coyote": there's no turning back, // you can arm but never disarm their devices. public func makeReadyToGoPow(acmeThingIdentifier: String) { // Find the specified device and do complex "ready to go pow" logic // ... } public func goPow(acmeThingIdentifier: String) { // Find the specified device and do complex "go pow" logic, then // remove it since it is no more. // ... things = things.filter { $0.acmeThingIdentifier != acmeThingIdentifier } } }
Just to get things working, he creates a quick and dirty view model class and a screen with a couple buttons and a list:
import AcmeSdk class ViewModel { private(set) var deviceIds = [String]() func scanTapped() { deviceIds = AcmeThingController.shared.scanForAcmeThings() } func armDeviceTapped(selectedDeviceId: String) { AcmeThingController.shared.makeReadyToGoPow( acmeThingIdentifier: selectedDeviceId ) } func explodeDeviceTapped(selectedDeviceId: String) { AcmeThingController.shared.goPow(acmeThingIdentifier: selectedDeviceId) } }
OK, things are working, Road Runner used his proof of concept app to find, arm and explode one of his devices. All good, but at $100 a pop (pun intended), that was an expensive test! Time to tighten things up, and with his life depending upon his view model code working correctly and all, unit tests are a must. Being smart, he obviously knows he needs to create an abstraction around the ACME code and then depend upon the abstraction instead of the ACME code in his view model. He knows he can do this with Swift protocols; in fact he knows Swift protocols are his go-to method for “rolling his own” mocks. He knows his app might need to support other explosive device brands Wile E. Coyote is experimenting with, and he knows the ACME SDK is frequently updated with breaking changes. He also doesn’t like those silly names in the ACME code either, so he’s going to create a device API that’s a little more palatable and hides some of the inner workings of the “backend” code in his app. He wants to improve things with the following objectives in mind:
- Limit the code in his app that is dependent upon the ACME SDK (he has other screens up his sleeve that will also interact with the ACME SDK).
- Make his view model unit testable (he can’t mock the AcmeThingController singleton, therefore he can’t unit test the view model yet).
- Enable running his app on the iOS simulator (which doesn’t support BLE, which the ACME SDK depends upon.)
- Enable manually testing his app’s UI without having to go to the firing range and spend $100 per test.
He knows he needs to do the following:
- Create a Swift protocol that does what AcmeThingController does but with more sensible method names. He’ll call it DeviceController.
- Extend AcmeThingController so that it conforms to the DeviceController protocol. Note that this code is also not unit testable, and that’s why he’ll keep it “thin”.
- Make the view model dependent upon DeviceController instead of the ACME SDK and inject the AcmeThingController singleton into the view model.
- Create a “dummy” implementation of DeviceController that prints log statements instead of blowing things up. He can use an instance of this class when running on the simulator and to remove the requirement for having real devices present when he’s developing his UI.
- Create mock implementation(s) of DeviceController and inject them into his view model in unit tests as needed to achieve test objectives.
Here’s the abstraction he came up with. It maps pretty closely to what AcmeThingController does:
/// Abstracts interactions with explosive devices. protocol DeviceController { func scanForDevices() -> [String] func arm(deviceId: String) func ignite(deviceId: String) }
Now he needs to “hide” the ACME code behind that abstraction. In a Flutter app, he might create a class that implements DeviceController, but in Swift he can extend AcmeThingController to make it conform to (“look like”) a DeviceController. Then, wherever he needs a DeviceController, he can simply pass in the ACME singleton (or in Flutter, an instance of the class that implements DeviceController):
import AcmeSdk /// AcmeThingController extension that adds DeviceController conformance. extension AcmeThingController: DeviceController { func scanForDevices() -> [String] { scanForAcmeThings() } func arm(deviceId: String) { makeReadyToGoPow(acmeThingIdentifier: deviceId) } func ignite(deviceId: String) { goPow(acmeThingIdentifier: deviceId) } }
Using the DeviceController abstraction now allows him to create another implementation that can be used on the iOS simulator, or while developing the UI at his desk:
/// DeviceController implementation that simply prints to the console class SimulatedDeviceController: DeviceController { private let deviceIds = ["1", "2", "3"] func scanForDevices() -> [String] { deviceIds } func arm(deviceId: String) { print("Armed \(deviceId)") } func ignite(deviceId: String) { print("Ignited \(deviceId): POW!") } }
Now we can modify the view model so the DeviceController is injected, in this case via its constructor, though some other dependency injection method could be used:
class ViewModel { private(set) var deviceIds = [String]() private let deviceController: DeviceController init(deviceController: DeviceController) { self.deviceController = deviceController } func scanTapped() { deviceIds = deviceController.scanForDevices() } func armDeviceTapped(selectedDeviceId: String) { deviceController.arm(deviceId: selectedDeviceId) } func explodeDeviceTapped(selectedDeviceId: String) { deviceController.ignite(deviceId: selectedDeviceId) } }
In the code that generates the view model instance, he can now decide whether to inject the real or simulated device controller:
let viewModel = ViewModel(deviceController: isIosSimulator() ? SimulatedDeviceController() : AcmeThingController.shared)
And finally, he can now fully unit test his (admittedly simple) view model by creating and injecting a mock implementation for DeviceController. This hand-rolled mock can do whatever makes testing easier; this is just a simple example:
/// A mock DeviceController for unit testing purposes. class MockDeviceController: DeviceController { var scanCount = 0 var armCount = 0 var igniteCount = 0 private let mockDeviceIds: [String] init(deviceIds: [String]) { mockDeviceIds = deviceIds } func scanForDevices() -> [String] { scanCount += 1 return mockDeviceIds } func arm(deviceId: String) { armCount += 1 } func ignite(deviceId: String) { igniteCount += 1 }
And finally, Road Runner can now write a unit test that ensures his view model interacts with the DeviceController correctly:
import XCTest func testViewModel() { let expectedDeviceId = "Device ID" let mockController = MockDeviceController(deviceIds: [expectedDeviceId]) let subject = ViewModel(deviceController: mockController) subject.scanTapped() XCTAssertEqual(subject.deviceIds, [expectedDeviceId]) XCTAssertEqual(mockController.scanCount, 1) XCTAssertEqual(mockController.armCount, 0) XCTAssertEqual(mockController.igniteCount, 0) }
There you have it. Road Runner’s higher level code is now decoupled from the ACME SDK’s API. He’s isolated his not-testable code to one file that has minimal code in it, so when he runs code coverage metrics, he can confidently ignore that file. He has more unit tests and the confidence that comes with it as he maintains his code going forward. He has the ability to run his app without the need for real devices and the cost and difficulty that sometimes brings, and he can run it on the iOS simulator. Finally, if ACME changes their SDK API, he can insulate the rest of his code from those changes, and potentially support other similar devices that require their own SDKs.
While this example is in Swift, similar concepts can be utilized in other languages with similar benefits. This example was purposefully made trivial to allow the higher level concepts to stand out, and so it might be difficult to appreciate the extra work involved in following this methodology here, but isolating third-party libraries has real utility and is something we do regularly at SpinDance in our work with connected devices. Hopefully you now have a little more motivation – and ammunition – to push your unit test coverage a little farther. Meep meep!