Do `any` and `some` help with "Protocol Oriented Testing" at all?

I'm a card-carrying member of Team Crusty and I regularly employ the techniques outlined in Brian Croom and Stuart Montgomery's 2018 WWDC talk "Testing tips & Tricks" when I unit test interactions with system frameworks. But there are situations when these won't work, specifically the case where you want to use a protocol to mock a framework call which returns a type you can't construct. There are a bunch of these in CoreBluetooth, for example. Consider CBCentralManager's retrieveConnectedPeripherals(withServices:) signature:

func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBUUID]) -> [CBPeripheral]

What I'd like to do is create a protocol called Peripheral with name and state properties, and make CBPeripheral conform to it:

protocol Peripheral {
    var name: String { get }
    var state: CBPeripheralState { get }
}

extension CBPeripheral: Peripheral {}

... and a protocol called CentralManager, with a method like:

protocol CentralManager {
    func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBUUID]) -> [Peripheral]
}

Then in theory I could say extension CBCentralManager: CentralManager {} and inject a StubCentralManager into my unit under test which could return Peripherals with known names and states. But this doesn't work, because even though CBPeripheral "is-a" Peripheral, Swift does not (yet?) support covariant return types*. And I can't just have my retrieveConnectedPeripherals(withServices:) method on CentralManager return a normal CBPeripheral because its constructor is private.

This comes up all the time attempting to test interactions with system frameworks (currently banging my head against AVAudioEngine and friends) and the only way I've found around it is to build thin wrappers around the system frameworks. But that's ugly, verbose and error prone.

Do the new some or any keywords help with this?

(* Forgive me if this isn't the right terminology, I am but a humble application developer.)

4 Likes

This doesn't have to do anything with some and any, but you can fix this particular problem by introducing an associated type for the peripheral type to the CentralManager protocol, like this:

protocol CentralManager {
    associatedtype PeripheralType: Peripheral

    func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBUUID]) -> [PeripheralType]
}

// Now this compiles, the compiler infers `typealias PeripheralType = CBPeripheral`
extension CBCentralManager: CentralManager {}

Now whether this solves all of your problems or if it even introduces new ones is hard to say.

Depending on the context, Swift 5.7 can make this technique more broadly applicable because it allows protocols with associated types to be used in many more places. E.g. in Swift 5.7 you can still have an array [any Peripheral], which would be a compiler error in Swift 5.6 if the protocol has an associated type.

4 Likes

Given we're only doing this in test code, does anyone know any dirty tricks we can do to from Swift to construct an instance of an Objective-C class whose init is private? Or would they all have to be done from Obj-C and called from Swift?

Yes they will help. But seeing how involves revisiting some notions about what protocols are for and how to use them.

As @ole says, you can model CentralManager as a protocol with an associated type, PeripheralType. Note that this is the approach taken in some of the examples in the Testing Tips & Tricks video.

You can then implement a mock version of this protocol, avoiding the need to create dummy CBPeripheral instances:

struct MockCentralManager: CentralManager {
    struct MockPeripheral: Peripheral {
        var name: String? = "FakePeripheral"
        var state: CBPeripheralState = .connecting
    }
    
    func retrieveConnectedPeripherals(
        withServices serviceUUIDs: [CBUUID]
    ) -> [MockPeripheral] {
        [MockPeripheral()]
    }
}

So now we have two types, CBCentralManager and MockCentralManager, which both conform to the CentralManager protocol and return an array of types conforming to Peripheral.

They do this without needing support for covariance in their conformance. The trouble with the covariant solution is demonstrated by the fact that it involves returning an Array. Languages that support transparent substitution of a type with a super type are usually pointer-based languages, like Java or Objective-C. If everything is a pointer to an Object subclass, then this kind of substitution can be performed for free. You have an array of pointers before, and then you just declare to the compiler "this is an array of <supertype>" and it says OK and you're done.

Swift is not a pointer-based language though. In the example above, MockPeripheral is a struct, and is 24 bytes in size, and instances of it will be held directly inline within the array. This approach has a lot of advantages. For example, it allows Swift to have a 2-word String type with a capacious small string implementation. But what it means is that [MockPeripheral] and [CBPeripheral] are not transparently substitutable.So instead of techniques like type erasure and covariance, Swift solves this with generics – with a protocol with an associated type. You can then write generic functions using that protocol as a constraint, and use the associated type to refer to the real type being used by either implementation.

How do some and any help with this

This protocol-based approach seems like a neat solution to the problem, but if you tried to use it in Swift 5.6 you'd immediately hit several problems.

Firstly, writing generic functions is much more fiddly than writing concrete functions. So instead of

func fetchAndProcess(with manager: CBCentralManager)

if you want this function to work with either the real type or your mock type, you have to write

func fetchAndProcess<T: CentralManager>(with manager: T)

some in 5.7 makes this simpler:

func fetchAndProcess(with manager: some CentralManager)

There is a discussion currently about whether Swift 6 should allow you to even drop the some, giving you the instinctively "right" syntax you'd probably expect coming from other languages while still using Swift's generics to express this without type erasure. In my view, this change will be critical to help users like yourself see the generic solution as the natural path to follow to solve this kind of problem, instead of the path of type erasure that Swift's current defaults encourage (and that seem "natural" if you're coming from a language that favors subtyping instead of generics).

Second, the problem is this need to use generics spreads like a virus. Once you start using generics like this, you find you have to write everything generically. This can get out of hand, and so it's useful to put a stop to it at some point in your code. Previously this was really hard but in Swift 5.7 you have two good solutions to firewall off the generics at a good point:

// dynamically choose mocking at runtime based on a variable
let manager: any CentralManager =
    mockPeripherals
    ? MockCentralManager
    : CBCentralManager

// choose at compile time, based on a compilation flag
#if MOCK_PERIPHERALS
let manager: some CentralManager = MockCentralManager()
#else
let manager: some CentralManager = CBCentralManager()
#endif

In the first option, you can now create a box type that can hold either the mock or real peripheral manager, and switch between them at runtime. This wasn't previously possible with protocols that had associated types. You can now also pass the any CentralManager into functions that take a generic some CentralManager. The Swift compiler will just make this work. This is important because the any CentralManager type will "erase" the PeripheralType so you won't be able to work with it easily. But within a generic function (including a method added via extension CentralManager) you'll be able to use the associated type to refer to the exact Peripheral-conforming type returned by this particular manager.

In the second option, you can also use some CentralManager to store a manager fixed at compile time. The some isn't really necessary, you could just use the two concrete types at compile time between the #if, but it can be nice to make sure you're sticking only to the protocol interface in all your code to avoid a situation where you use a feature you haven't mocked and only find out at test time.

32 Likes

@Ben_Cohen Thank you for the detailed write up! I for one hope Swift 6 follows the approach you mention. It seems if we know at compile time whether the generic is being used existentially or in a boxed fashion, that the compiler should do the ‘right’ or natural thing but allow for further specialization / distinction where desired. As much as I appreciate the additions of any and some - they seem to me to leak the abstraction in most cases where the programmer’s intent should have been obvious. Really more an argument against the ‘death by a thousand cuts’ impact of leaky abstractions.