12 Sep 2019
If you want to test your Swift code, at some point you’re probably going to need to make some mocks in order to isolate a type that you’re testing from system apis. For instance, if your type calls UNNotificationCenter
, then in your tests you don’t want it to call the real UNNotificiationCenter
, but rather a mock substitute that you control.
If you do some research on how to do this, you’ll likely come across the following strategy:
This works, and I’ve successfully used this strategy myself. I think there’s some issues with this approach though, and for many cases there may be a better alternative. Let’s take a deeper look at the issues that you might be introducing using this technique, and then I’ll propose a possible better solution.
I’m going to refer to the technique described above as the ‘shadowing’ technique. Essentially, we have a concrete type that we don’t own, and we want to have an abstraction over it. We create a protocol with the same api as the concrete type, and refer that instead. Here’s an example with FileManager
:
protocol FileManagerProtocol: class {
func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer<ObjCBool>?) -> Bool
}
extension FileManager: FileManagerProtocol {}
The type we’re testing will only know about FileManagerProtocol
, so in production we’ll inject the real FileManager
, and in our tests we’ll inject a MockFileManager
:
class MockFileManager: FileManagerProtocol {
var fileExists = true
var isDirectory = false
func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer<ObjCBool>?) -> Bool {
isDirectory?.pointee = self.isDirectory
return fileExists
}
}
Of course, our test can alter the fileExists
and isDirectory
properties of the MockFileManager
in order to assert that a specific behaviour occurs when a file exists or not. Lets look at a few downsides with this approach though.
Both Objective-C and Swift support annotations to mark types or methods as deprecated. When we call a deprecated method in our code, we will trigger a compiler warning, letting us know that we need to update to a newer version of the api.
These annotations only exist on the concrete type though. If we refer to the type through a protocol, even with the same api, then the compiler won’t warn us at all. This can allow usage of deprecated apis to go unnoticed.
Let’s take a look at that method signature again:
func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer<ObjCBool>?) -> Bool
Personally, I’m not a fan. There’s clearly a bit of Objective-C era baggage here. No one really wants to deal with UnsafeMutablePointer<ObjCBool>?
if they can avoid it, right?
It would be nicer if the api looked more like this:
func fileExists(atPath path: String) -> Bool
func directoryExists(atPath path: String) -> Bool
Given that we think this api is awkward to work with, why do we want to proliferate it around our codebase?
By mirroring the api of a type, we’re creating a code level abstraction over it, because we can refer to it via the interface instead of the concrete type.
What we’re missing, though, is the opportunity to create a semantic abstraction. Imagine a new and improved api becomes available for working with the file system. It’s unlikely that it’s going to have this exact method:
func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer<ObjCBool>?) -> Bool
We’d have to change the FileReader
protocol anyway, which means that we’ll have to alter every call-site, which means our abstraction didn’t buy us much.
There’s also something that doesn’t sit right with me about letting a third party api dictate the api for my abstraction. I want to own my abstractions! If I need to work with a type that reads files, I’d prefer to decide on what public api that would have, and then lean on a third party type inside of the implementation, rather than have the api of the third party type dictate to my application what the interface to read files should be.
There are limits here, of course. If the api that you wish to have and the api that the third party type has don’t map easily on to each other, then you may need an additional layer of abstraction between them that you can test.
Let’s look at another approach, where we’ll wrap the type that we want to abstract away from rather than ‘shadow’ its api.
That would look more like this:
protocol FileReader {
func fileExists(atPath path: String) -> Bool
func directoryExists(atPath path: String) -> Bool
}
class FileReaderImpl: FileReader {
func fileExists(atPath path: String) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return exists && !isDirectory
}
func directoryExists(atPath path: String) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return exists && isDirectory
}
}
So now we can do this:
let fileReader: FileReader = FileReaderImpl()
let fileExists = fileReader.fileExists(atPath: "path/to/file")
You might shudder a bit at the FileReaderImpl
name - I did the first time I used this approach. We shouldn’t really worry about it though, because we should only initialise the type once, and inject it in to an object that depends on it. Any other references to this type should be through the FileReader
protocol.
Let’s see what we’ve earned ourselves:
Mocking just became a lot easier, because we’re using an api that is more to our taste than the original.
class MockFileReader: FileReader {
var findsFile = true
var findsDirectory = true
func fileExists(atPath path: String) -> Bool {
return findsFile
}
func directoryExists(atPath path: String) -> Bool {
return findsDirectory
}
}
// We can just inject the mock in to the type that we want to test
let mock = MockFileReader()
mock.findsFile = false
let objectToTest = MyObject(fileReader: mock)
We only ever call the FileManager
type directly inside of FileReaderImpl
. Because we’re not re-declaring its api in a protocol, if any of its methods become deprecated then we’ll get deprecation warnings!
If a newer and better thing comes out than FileManager
, then we can swap for a different implementation, whilst still keeping the same api that we designed. In fact, our code is actually completely abstracted from the FileManager
type, it only knows about FileReader
s. We can change the innards of FileReaderImpl
to utilise a different underlying type and our code would happily work with it.
Ok, technically, if you want to nit-pick, we lost a tiny bit of testability. Because we wrapped the FileManager
in a FileReader
protocol, we’re unable to test the inside of FileManagerImpl
to ensure that it actually interacted with FileManager
in the correct way.
Personally, I don’t think this is too much of a loss. The implementation inside of the wrapper should be so simple that a bug is unlikely and would be squashed early. You can think of it as a very basic translation layer from the protocol api (FileReader
) to the concrete type’s api (FileManager
). In some cases you may even be just forwarding a call with the same method signature.
The benefits outweigh the costs in my opinion, and let’s be honest - your code base probably isn’t so tested that you’ll lose sleep about that little binding function inside of a wrapper!