Steve On Stuff

Own Your Abstractions

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.

The shadowing technique

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.

No deprecation warnings

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.

The api might not be great anyway

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?

It’s not really abstraction

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.

The wrapping technique

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:

Easy Mocking

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)

Deprecation warnings are back!

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!

We have a semantic abstraction

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 FileReaders. We can change the innards of FileReaderImpl to utilise a different underlying type and our code would happily work with it.

Ok, so what’s the catch?

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!

Next »


« Previous




I'm Steve Barnegren, an iOS developer based in London. If you want to get in touch: