Wrapping a difficult dependency

Most of us have to deal with integrating 3rd party APIs into our apps for a multitude of reasons. Wisdom tells us to wrap dependencies and make them testable but a lot of vendors unintentionally make this really difficult. In this post we are going to look at an API that has several design flaws that make it awkward to isolate and then we will run through the various techniques to tame the dependency.

Let’s start by looking at a stripped down example that shows a few of the issues

public class ThirdParty {
    public static var delegate: ThirdPartyDelegate?
    public static func loadFeedbackForm(_ id: String) { /* ... */ }
}

public protocol ThirdPartyDelegate {
    func formDidload(_ viewController: UIViewController)
    func formDidFailLoading(error: ThirdPartyError)
}

public struct ThirdPartyError: Error {
    public let description: String
}

The design decisions that the third party author has made make this tricky to work with for a few reasons:

  • delegate and loadFeedbackForm are both static making this a big ol’ blob of global state
  • It’s using a delegate pattern, which is a bit old school so we’ll want to convert to a callback based API
  • ThirdPartyError has no public init so we can’t create an instance our self for our tests
  • The delegate callbacks don’t give any indication of what ID they are being invoked for

Figuring out the high level plan

We don’t want our application to use ThirdParty directly as it ties us to the 3rd party API design and we can’t really test effectively. What we can do is create a protocol for how we want the API to look and then make a concrete implementation of this protocol for wrapping the dependency. The high level view will look like this:

Class diagram when adding an interface

A reasonable API might look like this:

protocol FeedbackService {
    func load(_ id: String, completion: @escaping (Result<UIViewController, Error>) -> Void) throws
}

With this set up our tests can verify the App behaviour by substituting a test double that implements FeedbackService so that we don’t hit the real ThirdParty implementation. These tests are not particularly interesting to look at as creating a class MockFeedbackService: FeedbackService is fairly easy.

However it is interesting to delve in to how to wrap and verify the ThirdParty dependency itself with all its design warts.

Wrapping the dependency

The first design pain is that delegate is a static var and the delegate callbacks do not return any identifier to differentiate multiple loadFeedbackForm calls. From this we can take the decision to make it an error to have more than one loadFeedbackForm call in flight at any one time.

We can use the fact that if the delegate is non-nil then we must already have an inflight request. To write a test to cover this we can create an instance of ThirdPartyFeedbackService and provide a stubbed function for fetching the delegate, the production code (see below the test) will default this function to proxying to ThirdParty.delegate. Hardcoding this getDelegate function to return a non-nil value means that any subsequent call to load should throw an exception:

func testWhenLoadIsInFlight_subsequentLoadsWillThrow() throws {
    let subject = ThirdPartyFeedbackService(getDelegate: { ThirdPartyFeedbackService() })
    
    XCTAssertThrowsError(try subject.load("test", completion: { _ in })) { error in
        XCTAssertTrue(error is ThirdPartyFeedbackService.MultipleFormLoadError)
    }
}

The production code to make this pass would look like this

public class ThirdPartyFeedbackService: FeedbackService {
    private let getDelegate: () -> ThirdPartyDelegate?
    
    public init(getDelegate: @escaping () -> ThirdPartyDelegate? = { ThirdParty.delegate }) {
        self.getDelegate = getDelegate
    }
    
    public func load(_ id: String, completion: @escaping (Result<UIViewController, Error>) -> Void) throws {
        guard getDelegate() == nil else {
            throw MultipleFormLoadError()
        }
    }
    
    public struct MultipleFormLoadError: Error {}
}

extension ThirdPartyFeedbackService: ThirdPartyDelegate {
    public func formDidload(_ viewController: UIViewController) {}
    public func formDidFailLoading(error: ThirdPartyError) {}
}

The next thing to verify is that a normal load call correctly sets the delegate and also that we pass along the right id to the underlying ThirdParty dependency. Again we don’t want to actually invoke ThirdParty so we need to provide stubs for loadFeedback and setDelegate that would be set to the production in normal usage. A test to exercise this might look like this:

func testLoadSetsDelegateAndInvokesLoad() throws {
    // Given
    var capturedFormID: String?
    var capturedDelegate: ThirdPartyDelegate?

    let subject = ThirdPartyFeedbackService(
        loadFeedback: { capturedFormID = $0 },
        getDelegate: { nil },
        setDelegate: { capturedDelegate = $0 }
    )

    // When
    try subject.load("some-id") { _ in }

    // Then
    XCTAssertEqual("some-id", capturedFormID)
    XCTAssertTrue(subject === capturedDelegate)
}

To make this pass we can update the production code like this:

 public class ThirdPartyFeedbackService: FeedbackService {
+    private let loadFeedback: (String) -> Void
     private let getDelegate: () -> ThirdPartyDelegate?
+    private let setDelegate: (ThirdPartyDelegate?) -> Void
    
     public init(
+        loadFeedback: @escaping (String) -> () = ThirdParty.loadFeedbackForm,
         getDelegate: @escaping () -> ThirdPartyDelegate? = { ThirdParty.delegate },
+        setDelegate: @escaping (ThirdPartyDelegate?) -> Void = { ThirdParty.delegate = $0 }
     ) {
+        self.loadFeedback = loadFeedback
         self.getDelegate = getDelegate
+        self.setDelegate = setDelegate
     }
     
     public func load(_ id: String, completion: @escaping (Result<UIViewController, Error>) -> Void) throws {
         guard getDelegate() == nil else {
             throw MultipleFormLoadError()
         }
         
+        setDelegate(self)
+        loadFeedback(id)
     }
     
     public struct MultipleFormLoadError: Error {}
 }
 
 extension ThirdPartyFeedbackService: ThirdPartyDelegate {
     public func formDidload(_ viewController: UIViewController) {}
     public func formDidFailLoading(error: ThirdPartyError) {}
 }

With the above modifications we know we are setting things up correctly so the next step is to verify that a successful load will invoke our completion handler. This test will exercise this for us:

func testWhenLoadIsSuccessful_itInvokesTheCompletionWithTheLoadedViewController() throws {
    // Given
    var capturedDelegate: ThirdPartyDelegate?
    let subject = ThirdPartyFeedbackService(
        loadFeedback: { _ in }, 
        getDelegate: { nil }, 
        setDelegate: { capturedDelegate = $0 }
    )
    let viewControllerToPresent = UIViewController()
    
    var capturedViewController: UIViewController?
    
    try subject.load("some-id") { result in
        if case let .success(viewController) = result {
            capturedViewController = viewController
        }
    }
    
    // When
    subject.formDidload(viewControllerToPresent)
    
    // Then
    XCTAssertEqual(viewControllerToPresent, capturedViewController)
    XCTAssertNil(capturedDelegate)
}

This change makes the test pass:

 public class ThirdPartyFeedbackService: FeedbackService {
+    private var completion: ((Result<UIViewController, Error>) -> Void)?
     private let loadFeedback: (String) -> Void
     private let getDelegate: () -> ThirdPartyDelegate?
     private let setDelegate: (ThirdPartyDelegate?) -> Void
     
     public init(
         loadFeedback: @escaping (String) -> () = ThirdParty.loadFeedbackForm,
         getDelegate: @escaping () -> ThirdPartyDelegate? = { ThirdParty.delegate },
         setDelegate: @escaping (ThirdPartyDelegate?) -> Void = { ThirdParty.delegate = $0 }
     ) {
         self.loadFeedback = loadFeedback
         self.getDelegate = getDelegate
         self.setDelegate = setDelegate
     }
     
     public func load(_ id: String, completion: @escaping (Result<UIViewController, Error>) -> Void) throws {
         guard getDelegate() == nil else {
             throw MultipleFormLoadError()
         }
         
+        self.completion = { [setDelegate] result in
+            setDelegate(nil)
+            completion(result)
+        }

         setDelegate(self)
         loadFeedback(id)
     }
     
     public struct MultipleFormLoadError: Error {}
 }

 extension ThirdPartyFeedbackService: ThirdPartyDelegate {
     public func formDidload(_ viewController: UIViewController) {
+        completion?(.success(viewController))
     }
     
     public func formDidFailLoading(error: ThirdPartyError) {}
 }

Finally we need to handle the failing case:

func testWhenLoadFails_itInvokesTheCompletionWithAnError() throws {
    // Given
    var capturedDelegate: ThirdPartyDelegate?
    let subject = ThirdPartyFeedbackService(loadFeedback: { _ in }, getDelegate: { nil }, setDelegate: { capturedDelegate = $0 })
    let errorToPresent = NSError(domain: "test", code: 999, userInfo: nil)
    
    var capturedError: NSError?
    
    try subject.load("some-id") { result in
        if case let .failure(error) = result {
            capturedError = error as NSError
        }
    }
    
    // When
    subject.formDidFailLoading(error: NSError(domain: "test", code: 999, userInfo: nil))
    
    // Then
    XCTAssertEqual(errorToPresent, capturedError)
    XCTAssertNil(capturedDelegate)
}

This requires a fairly complex change because we can’t create a ThirdPartyError because the init is not public. Instead we need to work around this by making our function generic so that the compiler will write one implementation that works for ThirdPartyError types and one implementation that works with the Error type we provide in our tests.

 public class ThirdPartyFeedbackService: FeedbackService {
     private var completion: ((Result<UIViewController, Error>) -> Void)?
     private let loadFeedback: (String) -> Void
     private let getDelegate: () -> ThirdPartyDelegate?
     private let setDelegate: (ThirdPartyDelegate?) -> Void
     
     public init(
         loadFeedback: @escaping (String) -> () = ThirdParty.loadFeedbackForm,
         getDelegate: @escaping () -> ThirdPartyDelegate? = { ThirdParty.delegate },
         setDelegate: @escaping (ThirdPartyDelegate?) -> Void = { ThirdParty.delegate = $0 }
     ) {
         self.loadFeedback = loadFeedback
         self.getDelegate = getDelegate
         self.setDelegate = setDelegate
     }
     
     public func load(_ id: String, completion: @escaping (Result<UIViewController, Error>) -> Void) throws {
         guard getDelegate() == nil else {
             throw MultipleFormLoadError()
         }
         
         self.completion = { [setDelegate] result in
             setDelegate(nil)
             completion(result)
         }
 
         setDelegate(self)
         loadFeedback(id)
     }
     
     public struct MultipleFormLoadError: Error {}
 }
 
 extension ThirdPartyFeedbackService: ThirdPartyDelegate {
     public func formDidload(_ viewController: UIViewController) {
         completion?(.success(viewController))
     }
     
-    public func formDidFailLoading(error: ThirdPartyError) {     
+    public func formDidFailLoading<T: Error>(error: T) {
+        completion?(.failure(error))
     }
 }

How’d we do?

The design issues raised at the beginning made it harder to wrap the dependency and we had to make some decisions along the way like how we fail if multiple loads are called. We had to get a little creative to make this work and things like thread safety and cancellation are all things omitted for brevity.


Just for kicks

We might look at our protocol and think that it’s not really a useful abstraction to have. Migrating to using a simple value type is fairly mechanical and worth having a look to see how it pans out.

public struct FeedbackService {
    public let load: (_ form: String, _ completion: @escaping (Result<UIViewController, Error>) -> Void) throws -> Void
}

extension FeedbackService {
    public static func thirdParty(
        loadFeedback: @escaping (String) -> () = ThirdParty.loadFeedbackForm,
        getDelegate: @escaping () -> ThirdPartyDelegate? = { ThirdParty.delegate },
        setDelegate: @escaping (ThirdPartyDelegate?) -> Void = { ThirdParty.delegate = $0 },
        makeDelegate: () -> Delegate = Delegate.init
    ) -> FeedbackService {
        let delegate = makeDelegate()
        
        return .init { id, completion in
            guard getDelegate() == nil else {
                throw MultipleFormLoadError()
            }

            delegate.completion = { result in
                setDelegate(nil)
                completion(result)
            }

            setDelegate(delegate)
            loadFeedback(id)
        }
    }
    
    public class Delegate: ThirdPartyDelegate {
        var completion: ((Result<UIViewController, Error>) -> Void)?
        
        public init() {}
        
        public func formDidload(_ viewController: UIViewController) {
            completion?(.success(viewController))
        }

        public func formDidFailLoading<T: Error>(error: T) {
            completion?(.failure(error))
        }
    }
    
    public struct MultipleFormLoadError: Error {}
}

Looking at the above - the core of the logic does not change at all. We lose some lines as we are capturing the params to the thirdParty function and don’t need to create instance variables. We gain some lines by implementing an inline delegate class.

The tests can also be updated with minimal changes:

final class ThirdPartyFeedbackServiceTests: XCTestCase {
    func testLoadSetsDelegateAndInvokesLoad() throws {
        // Given
        let delegate = FeedbackService.Delegate()
        var capturedFormID: String?
        var capturedDelegate: ThirdPartyDelegate?

        let subject = FeedbackService.thirdParty(
            loadFeedback: { capturedFormID = $0 },
            getDelegate: { nil },
            setDelegate: { capturedDelegate = $0 },
            makeDelegate: { delegate }
        )

        // When
        try subject.load("some-id") { _ in }

        // Then
        XCTAssertEqual("some-id", capturedFormID)
        XCTAssertTrue(delegate === capturedDelegate)
    }

    func testWhenLoadIsInFlight_subsequentLoadsWillThrow() throws {
        let subject = FeedbackService.thirdParty(getDelegate: { FeedbackService.Delegate() })

        XCTAssertThrowsError(try subject.load("test", { _ in })) { error in
            XCTAssertTrue(error is FeedbackService.MultipleFormLoadError)
        }
    }

    func testWhenLoadIsSuccessful_itInvokesTheCompletionWithTheLoadedViewController() throws {
        // Given
        let delegate = FeedbackService.Delegate()
        var capturedDelegate: ThirdPartyDelegate?
        let subject = FeedbackService.thirdParty(loadFeedback: { _ in }, getDelegate: { nil }, setDelegate: { capturedDelegate = $0 }, makeDelegate: { delegate })
        let viewControllerToPresent = UIViewController()

        var capturedViewController: UIViewController?

        try subject.load("some-id") { result in
            if case let .success(viewController) = result {
                capturedViewController = viewController
            }
        }

        // When
        delegate.formDidload(viewControllerToPresent)

        // Then
        XCTAssertEqual(viewControllerToPresent, capturedViewController)
        XCTAssertNil(capturedDelegate)
    }

    func testWhenLoadFails_itInvokesTheCompletionWithAnError() throws {
        // Given
        let delegate = FeedbackService.Delegate()
        var capturedDelegate: ThirdPartyDelegate?
        let subject = FeedbackService.thirdParty(loadFeedback: { _ in }, getDelegate: { nil }, setDelegate: { capturedDelegate = $0 }, makeDelegate: { delegate })
        let errorToPresent = NSError(domain: "test", code: 999, userInfo: nil)

        var capturedError: NSError?

        try subject.load("some-id") { result in
            if case let .failure(error) = result {
                capturedError = error as NSError
            }
        }

        // When
        delegate.formDidFailLoading(error: NSError(domain: "test", code: 999, userInfo: nil))

        // Then
        XCTAssertEqual(errorToPresent, capturedError)
        XCTAssertNil(capturedDelegate)
    }
}

Conclusion

I quite like the second version and think it was worth taking the extra time to explore both approaches even if it boils down to personal preference. It would be nice if third party vendors made testing a first class concern rather than leaving us scratching our heads for work arounds or just accepting that we will call the live code. Hopefully some of the techniques/ideas are useful and no doubt I’ll find myself reading this post in a few months times when I have to wrap something else awkward.

Using a test suite for automation

Have you ever needed to automate a task that needs to be run on an iOS simulator and not known quite how to make it work in an unsupervised way? As an example - imagine we need to take some JSON files, run each one of them through an app and capture the generated UI so that the screenshots can be uploaded for further processing.


At a high level we want an executable that can take a directory full of input, run a task inside the simulator and place the output of all of the tasks into a different directory. We’ll start by looking at building something to run our task inside a simulator and then see how to get data in/out of the process.


Running our task

To run our code inside the simulator we can make use of a new test suite with a single test method. The test method will enumerate files found at the input directory path, execute some code on the simulator and store the results in a different directory. Here’s a basic implementation of the above:

import XCTest

class Processor: XCTestCase {
    func testExample() throws {
        let (inputDirectory, outputDirectory) = try readDirectoryURLs()
        let fileManager = FileManager.default
        
        try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
        
        try fileManager.contentsOfDirectory(at: inputDirectory, includingPropertiesForKeys: nil).forEach {
            try eval($0).write(to: outputDirectory.appendingPathComponent($0.lastPathComponent))
        }
    }
    
    private func readDirectoryURLs() throws -> (input: URL, output: URL) {
        func read(_ path: String) throws -> URL {
            URL(fileURLWithPath: try String(contentsOf: Bundle(for: Processor.self).bundleURL.appendingPathComponent("\(path)-directory-path")).trimmingCharacters(in: .whitespacesAndNewlines))
        }
        
        return try (read("input"), read("output"))
    }
}

The interesting things to note above are:

  • The input/output directory paths need to be written to files called input-directory-path and output-directory-path inside the test bundle
  • The eval function is a function that can read the contents of a file and return a result that we can write to the output directory - this is where all of the real work would happen

There are plenty of things that could be done to customise the above for individual use cases but it’s enough for this post.

How do we set up the input-directory-path and output-directory-path files inside the test bundle?


Wrapping the task

In order to inject the relevant paths we need to ensure that our test suite is built and then run as two separate steps. This gives us a chance to build the project, inject our file paths and then actually execute the test.

A Ruby script to do this would look something like the following:

#!/usr/bin/env ruby

unless ARGV.count == 2
  puts "USAGE: #{$PROGRAM_NAME} INPUT_DIRECTORY OUTPUT_DIRECTORY"
  exit 1
end

input_directory, output_directory = *ARGV

def xcode_build mode
  `xcodebuild #{mode} -scheme Processor -destination 'platform=iOS Simulator,name=iPhone 12,OS=14.5' -derivedDataPath ./.build`
end

xcode_build "build-for-testing"

Dir[".build/**/Processor.app"].each do |path|
  write = -> name, contents do
    File.open("#{path}/PlugIns/ProcessorTests.xctest/#{name}-directory-path", 'w') do |file| file.puts contents end
  end
  write["input", input_directory]
  write["output", output_directory]
end

xcode_build "test-without-building"

This script is doing the following:

  • Basic input validation to ensure that both an input and output path have been provided
  • Run xcodebuild with the action of build-for-testing to ensure that the test suite is built and not run
  • Write the input-directory-path and output-directory-path files into the test bundle
  • Run xcodebuild with the action of test-without-building to execute the test suite

With all of these pieces in place and assuming we named this script run-processor we can execute this script like this:

./run-processor /path/to/input /path/to/output

Result

We have a pretty bare bones implementation that should demonstrate the general idea and leave plenty of scope for expansion and experimentation.

The hidden cost of `@testable`

If a Swift module is compiled with “testing enabled” it allows us to import that module using the @testable annotation to alter the visibility of our code. Classes and their methods that are marked as internal or public become open, which allows subclassing and overriding in tests. Other API marked as internal becomes public so that it is now visible to tests.

This is certainly useful when it’s required but it can often be used too eagerly without taking into account some of the issues it can lead to. I’m going to look at a few potential design issues that could come from using @testable. This post is not saying if you use @testable bad things will happen but it’s worth keeping in mind some of the design trade offs you are making.

All the issues I’m going to discuss have a common thread that revolves around my understanding of public API so it’s worth clarifying what I’m encompassing when referring to public API.

Public API

When an API is marked as public in code that is going to be shared it represents a commitment from the author. The commitment is to the consumers of the code that public APIs will be stable, supported and the behaviour will not change unless some change management process is followed. It is therefore beneficial for code authors to keep the surface area of their public APIs as small as possible and hide as much implementation from end users. This set up gives the author the freedom to rework the internals as much as they like and as long as the observable public API remains unchanged then downstream users won’t bat an eyelid.

With that explained let’s look at some of the issues:


Overly specified code

Adding tests around code makes the code harder to change because we are locking in the behaviour or at the very least our current understanding of the behaviour. This is great for public APIs because we already discussed that public APIs should be stable. This rigidity is not so good for our non public implementation details that we want to be easier to change.

I’m sure we’ve all had this internal dialog with ourselves at some point

I only wrote these tests last week, why is it hindering my refactoring rather than helping?

This is normally a sign that we got carried away and we are testing the implementation details rather than the overall behaviour. @testable makes this problem much easier to come by. There’s been plenty of times I’ve hit code visibility issues in my tests and instinctively reached for @testable import instead of opting to mark the API I want to test as public. The issue is, once the big ol’ @testable switch has been flipped it’s much easier to overly specify your code and write tests at the wrong level.

There are of course exceptions but I’d try to selectively mark things as public and prefer to only test those APIs. This does not mean that the code is any less tested, it’s just that the code is being exercised indirectly. If there is code that is not exercised when going through the public API then it’s probably just dead code that needs removing.


Loosens documentation and forces extremes

Something that @testable takes away from us is the documentation that we get when we mark an API as public. As the default visibility for code is internal it means that unless otherwise stated all code you write in a single module is visible everywhere within that module. This makes it really hard to differentiate what code should be stable and what code should be flexible.

This documenting of stable API is forced upon us, in a good way, when using multiple modules or we won’t be able to see any code from the imported modules. Unfortunately this is probably not the common case as many people will come to Swift for app development where working within a single module is the norm.

To resolve this we can go to extremes and mark all implementation details as private but this then removes our ability to use the escape hatch of @testable. As a reminder this post is not saying @testable is bad as there are many times where you genuinely might get value from testing implementation details that you don’t want to be public.


But I use TDD

Using a TDD approach is not a panacea and when teamed with @testable I think it’s a winning combination to make it easy to fall into these design traps. I’ve seen people TDD some code and come up with good solutions but then fall at the last hurdle. It’s easy to forget that that tests are not the artifact we care about producing, instead they just help create working software. The last step that is often missed is to ask the question

Are these tests at the right level?

Keeping in mind that tests make things less flexible and the things we preferably want to be stable are the public API. We should therefore see if we can restate any tests that are aimed at implementation details as tests of the public API.

I’ve been bitten by this many times where I’ve TDD’d some code and then returned sometime later to find it requires a lot of rework of tests to get things moving. This can often cause so much friction that I’ll just opt to leave the code to rot and incur more debt.


Different compilation

For @testable import to work your Swift module needs to be compiled differently. I’m assuming it’s entirely safe as much smarter people than me decided it would be a good addition but I can’t help but feel most uses are unnecessary. By choosing to lightly sprinkling code with public you get the benefits of a smaller public API surface area, better documentation of intent and the compiler is not having to do any special work.


Conclusion

There are no hard rules and context is key when making decisions. As a starting point I’d mark things as public rather than use @testable as this forces you to consider how stable you want this API to be. Also I’d use the visibility modifiers on code even when working within a single module to signal intent to future readers about whether the code should remain stable or is free to change.

Basically - don’t be afraid to use @testable just don’t make it the default tool.