Testing, mocking and dependency injection

All the most important AbleLib classes - AbleManager, AbleComm, AbleDeviceStorage, AbleGattServer and sockets - are actually implementations of interfaces/protocols - IAbleManager, IAbleComm, IAbleDeviceStorage, IAbleGattServer, etc.. This allows for easy mocking when testing and plays nicely with dependency injection/resolution. Instead of using AbleManager.shared (which creates AbleComm instances) or AbleDeviceStorage.default, you can provide your own mocked implementations for testing purposes and easily tie them into your code. You can even mock or substitute some of those key implementations, and have the AbleLib ecosystem keep working as if nothing happened.

Dependency injection and mocking

Let's assume you're using Koin. Instead of using AbleManager.shared, define the IAbleManager providers like this:

val myModule = module {
    single<IAbleManager> { AbleManager.shared }
}

val mockModule = module {
    single<IAbleManager> { 
        object : IAbleManager() {
            // implement desired mocked functionality
        }
    }
}

Then, use the injected instance where necessary:

val ableManager: IAbleManager by inject()
...
ableManager.scan { result ->
...
}

Mocking comm

You can implement a custom IAbleComm to simulate, mock or alter some or all parts of the peripheral communication. You can then inject this custom implementation into the AbleLib ecosystem by implementing a custom IAbleManager and providing instances via its comm method.

Mocking comm is a three-step process:

  1. Implemementing mock IAbleManager.
  2. Implementing mock IAbleComm.
  3. Instantiating mock comm in IAbleManager.comm method.

If you plan on mocking comm, make sure not to use ableDevice.comm (or asyncComm) to when instantiating comm, as these always return the default SDK implementation (AbleComm). Instead, always use ableManager.comm.

class MockComm(val params: AbleCommParams, 
               override val callback: AbleCommCallback
) : IAbleComm {
...
}

class MockAbleManager : IAbleManager {
    ...
    override fun comm(params: AbleCommParams, callback: AbleCommCallback): IAbleComm {
        return MockComm(params, callback)
    }
    ...
}

Then, later in your code, use it like this:

val ableManager: IAbleManager by inject()
...
val device = ...
val comm = AbleCoroutineComm(ableManager, AbleCommParams(device))
val asyncComm = AbleCommBuilder(ableManager, device)

Mocking GATT server and sockets

Mocking a GATT server is trivial - simply create your own implementation of IAbleGattServer/IAbleGATTServer and have your custom implementation of IAbleManager return it in its startGattServer/startGATTServer.

Mocking sockets is even easier, since they aren't tied to other parts of the AbleLib ecosystem. You just need to make your mock implementations of IAbleSocketConnection, and have your mocked IAbleSockets and/or IAbleServerSockets use it.

Caveats - methods/classes that internally use AbleManager.shared

Some convenience methods or extensions internally use AbleManager.shared, so please be mindful not to use them if you plan on using your own implementation of IAbleManager. Here's an exhaustive list of these methods for each platform:

  • AbleDevice.pair().
  • AbleDevice.pair(callback).
  • AbleDevice.comm().
  • AbleDevice.asyncComm().
  • AbleComm generally uses AbleManager.shared for a lot of state tracking and internal actions.
  • AbleDeviceStorage.default.importDevices(deviceList).
  • AbleTask has an overridable field manager that, by default, points to AbleManager.shared.
  • AbleGattServer uses AbleManager.shared for a lot of state tracking and internal actions.
  • L2capSocket and L2capServerSocket use AbleManager.shared.