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.