What I Learned Building the PusherSwift Framework

building_pusher_swift_framework_blog_header.jpg

Creating a framework in the Apple developer ecosystem is a bit of a mixed bag. Some of the tools you come into contact with are intuitive, reliable, and a joy to use. Others are incredibly frustrating and you can lose hours (and days) to battling with them, before eventually emerging either victorious or completely disillusioned. \[…\]

Introduction

Creating a framework in the Apple developer ecosystem is a bit of a mixed bag. Some of the tools you come into contact with are intuitive, reliable, and a joy to use. Others are incredibly frustrating and you can lose hours (and days) to battling with them, before eventually emerging either victorious or completely disillusioned.

I’ve written up some of the things I’ve discovered more recently that have helped me get the PusherSwift library to a point where, at least for me, it’s very easy to make changes confidently, quickly, and without going slightly insane each time.

Specifically I’m going to write about minimising the Xcode-specific overhead that multi-platform frameworks typically bring with them, using XCTest to write synchronous and asynchronous tests, creating mocks for your tests, and a few tips for getting your tests working reliably and quickly on Travis CI.

Single-target build

PusherSwift works on iOS, tvOS, and macOS (and hopefully watchOS as well in the near future!). Prior to version 3 of the library there were platform-specific targets for the framework, as well as platform-specific test targets. 6 targets. Nobody wants to have to maintain 6 targets in a project when you don’t have to. It’s a hassle to ensure that each target has the correct set of files setup to be compiled and that each test target is running all of the tests that you’ve written.

As it turns out it’s actually not that hard to boil down multiple targets into a single one. So that’s exactly what we did with PusherSwift. There’s now a single PusherSwift target in the project which you can build and run tests with, using iOS, tvOS, or macOS devices.

If your framework does also support watchOS then don’t worry; you can easily also add in watchOS and watchsimulator to the list of supported platforms for your framework’s target and it’ll work in much the same way (platform-specific tweaks permitting).

Having a single target is all well and good but adopting this setup also means that you’ll only need a single scheme for all of the various builds you need for the different platforms your framework supports.

Doing this to your project, if you’re making a framework for other people to use, probably won’t make much of a tangible difference to most people. However, for those who do want to contribute it should make things a lot easier to work with.

XCTest

My most frequently used language prior to Swift was Ruby and so I was pretty familiar with the excellent rspec. When it came to writing tests for PusherSwift I took an initial look at the XCTest framework and decided it wouldn’t cut the mustard. I soon discovered the excellent Quick and Nimble libraries that provide an rspec-like interface for writing tests. It certainly felt a lot more intuitive to me and I was very productive when writing tests using that setup.

The only thing that I wasn’t so fond of was the reliance on external dependencies. With the previous multi-target workflow it actually meant that using these dependencies via Cocoapods was remarkably simple, as you can see in the old Podfile I was using. However, moving to a single-target build makes this considerably more difficult, if not impossible. I couldn’t find a way to make it work using Cocoapods and only recently have I found a way to make it work with Carthage.

However, before I found out it was possible with Carthage I got frustrated enough that I decided that I’d use the opportunity to rewrite the tests using XCTest.

For the vast majority of the tests the translation from a test using Quick and Nimble to one using XCTest is very simple. For example, here’s one of the tests as it was previously written using Quick and Nimble.

1class PusherPresenceChannelSpec: QuickSpec {
2    override func spec() {
3        var pusher: Pusher!
4        var socket: MockWebSocket!
5
6        beforeEach({
7            let options = PusherClientOptions(
8                authMethod: .inline(secret: "secret")
9            )
10            pusher = Pusher(key: "key", options: options)
11            socket = MockWebSocket()
12            socket.delegate = pusher.connection
13            pusher.connection.socket = socket
14        })
15
16        describe("the members object") {
17            it("stores the socketId if no userDataFetcher is provided") {
18                pusher.connect()
19                let chan = pusher.subscribe("presence-channel") as? PresencePusherChannel
20                expect(chan?.members.first!.userId).to(equal("46123.486095"))
21            }
22        }
23    }
24}

Now the same test written using XCTest looks like this:

1class PusherPresenceChannelTests: XCTestCase {
2    var pusher: Pusher!
3    var socket: MockWebSocket!
4    var options: PusherClientOptions!
5
6    override func setUp() {
7        super.setUp()
8
9        options = PusherClientOptions(
10            authMethod: .inline(secret: "secret")
11        )
12        pusher = Pusher(key: "key", options: options)
13        socket = MockWebSocket()
14        socket.delegate = pusher.connection
15        pusher.connection.socket = socket
16    }
17
18
19    func testMembersObjectStoresSocketIdIfNoUserDataFetcherIsProvided() {
20        pusher.connect()
21        let chan = pusher.subscribe("presence-channel") as? PresencePusherChannel
22        XCTAssertEqual(chan?.members.first!.userId, "46123.486095", "the userId should be 46123.486095")
23    }
24}

However, it’s not always this easy a translation. Tests that rely on some asynchronous interactions need to be thought about a bit differently. The best way to explain it is to use another example.

This is how one of the authentication tests was written using Quick and Nimble. It’s testing that if you set an authEndpoint in your PusherClientOptions that when you then attempt to subscribe to a private channel then a request is made to the auth endpoint and, provided a suitable response is returned, the subscription succeeds.

1class AuthenticationSpec: QuickSpec {
2    override func spec() {
3        var pusher: Pusher!
4        var socket: MockWebSocket!
5
6        beforeEach({
7            let options = PusherClientOptions(
8                authMethod: AuthMethod.endpoint(authEndpoint: "http://localhost:9292/pusher/auth")
9            )
10            pusher = Pusher(key: "testKey123", options: options)
11            socket = MockWebSocket()
12            socket.delegate = pusher.connection
13            pusher.connection.socket = socket
14        })
15
16        describe("subscribing to a private channel") {
17            it("should make a request to the authEndpoint") {
18                if case .endpoint(authEndpoint: let authEndpoint) = pusher.connection.options.authMethod {
19                  setupMockAuthResponseForRequest(toEndpoint: authEndpoint)
20                }
21                let chan = pusher.subscribe("private-test-channel")
22                expect(chan.subscribed).to(beFalsy())
23                pusher.connect()
24                expect(chan.subscribed).toEventually(beTruthy())
25            }
26        }
27    }
28}

The new version of this test is a bit more involved. This is because of how assertions work when using XCTest. You can’t write an equivalent assertion to the expect(chan.subscribed).toEventually(beTruthy()) as in the test above. The solution to this is to create an XCTestExpectation . With an expectation object you can then specify when an expectation gets fulfilled. This is what makes them useful when writing tests where there are asynchronous operations that need to be performed because you can defer fulfilling an expectation until the appropriate moment (when the asynchronous code has run, and hopefully succeeded in performing its task).

This is what the test looks like now. I’ll explain it a bit further below.

1class AuthenticationTests: XCTestCase {
2    class DummyDelegate: PusherConnectionDelegate {
3        var ex: XCTestExpectation? = nil
4        var testingChannelName: String? = nil
5
6        func subscriptionDidSucceed(channelName: String) {
7            if let cName = testingChannelName, cName == channelName {
8                ex!.fulfill()
9            }
10        }
11    }
12
13    var pusher: Pusher!
14    var socket: MockWebSocket!
15
16    override func setUp() {
17        super.setUp()
18
19        let options = PusherClientOptions(
20            authMethod: AuthMethod.endpoint(authEndpoint: "http://localhost:9292/pusher/auth")
21        )
22        pusher = Pusher(key: "testKey123", options: options)
23        socket = MockWebSocket()
24        socket.delegate = pusher.connection
25        pusher.connection.socket = socket
26    }
27
28    func testSubscribingToAPrivateChannelShouldMakeARequestToTheAuthEndpoint() {
29        let ex = expectation(description: "the channel should be subscribed to successfully")
30        let channelName = "private-test-channel"
31
32        let dummyDelegate = DummyDelegate()
33        dummyDelegate.ex = ex
34        dummyDelegate.testingChannelName = channelName
35        pusher.connection.delegate = dummyDelegate
36
37        if case .endpoint(authEndpoint: let authEndpoint) = pusher.connection.options.authMethod {
38            setupMockAuthResponseForRequest(toEndpoint: authEndpoint)
39        }
40
41        let chan = pusher.subscribe(channelName)
42        XCTAssertFalse(chan.subscribed, "the channel should not be subscribed")
43        pusher.connect()
44
45        waitForExpectations(timeout: 0.5)
46    }
47}

At the start of the code block you can see that we’re defining a DummyDelegate. I’ll cover that in a moment. We’ll start where the test itself actually starts. We begin by creating our expectation. This is what we’ll fulfil later when, as the description attached to the expectation makes clear, the subscription has occurred successfully.

The part of the test that is asynchronous is the request to the authentication endpoint. This is because it is using URLSession underneath, which is asynchronous itself (by default). The overall aim of the test is to test that the subscription occurs successfully. As such, we need to find a way to wait until the request to the auth endpoint has succeeded and the channel has been marked as successfully subscribed to. Thankfully the PusherSwift framework allows you to setup a PusherConnectionDelegate on the connection object, which has an optional function that you can implement called subscriptionDidSucceed . We will make use of this function in the delegate to fulfil the expectation that we created at the start of the test.

To do so we need to let the delegate have access to the expectation. All this means is that our DummyDelegate needs to have a property that we can use to make the expectation available, which is what we’re doing with this line: dummyDelegate.ex = ex . The only things that remain are to setup an instance of our DummyDelegate and make it our connection’s delegate. Then we add the line at the end of the test, waitForExpectations(timeout: 0.5) to instruct it to wait up to 0.5 seconds for the expectations created in the scope of the test to be fulfilled, otherwise consider the test as having failed.

Note that it’s not just the delegate pattern that works with asynchronous testing. It works just as easily with callbacks, promises, observables, or any other asynchronous setup you might have.

Rewriting the tests using XCTest turned out to be a great decision. I discovered that I’m actually quite a big fan of XCTest and its simplicity. Not only that but I found one of the old tests wasn’t actually testing what I thought it was, so now the test suite is even better.

Mocking in Swift

The function called setupMockAuthResponseForRequest in the two authentication tests displayed above is doing some interesting things. As the name suggests, it’s setting up a mock auth response for the HTTP request that will be being made to the auth endpoint specified in the PusherClientOptions object.

The call to setupMockAuthResponseForRequest is doing a few things, specifically:

1let jsonData = "{\"auth\":\"testKey123:12345678gfder78ikjbg\"}".data(using: String.Encoding.utf8, allowLossyConversion: false)!
2let urlResponse = HTTPURLResponse(url: URL(string: "\(authEndpoint)?channel_name=private-test-channel&socket_id=45481.3166671")!, statusCode: 200, httpVersion: nil, headerFields: nil)
3
4MockSession.mockResponse = (jsonData, urlResponse: urlResponse, error: nil)
5pusher.connection.URLSession = MockSession.shared

What is this MockSession object, you might ask. It’s pretty simple really:

1public typealias Response = (data: Data?, urlResponse: URLResponse?, error: NSError?)
2
3public class MockSession: URLSession {
4    static public var mockResponses: [String: Response] = [:]
5    static public var mockResponse: (data: Data?, urlResponse: URLResponse?, error: NSError?) = (data: nil, urlResponse: nil, error: nil)
6
7    override public class var shared: URLSession {
8        get {
9            return MockSession()
10        }
11    }
12
13    override public func dataTask(with: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
14        var response: Response
15        let mockedMethodAndUrlString = "\(with.httpMethod!)||\((with.url?.absoluteString)!)"
16
17        if let mockedResponse = MockSession.mockResponses[mockedMethodAndUrlString] {
18            response = mockedResponse
19        } else {
20            response = MockSession.mockResponse
21        }
22        return MockTask(response: response, completionHandler: completionHandler)
23    }
24
25    public class func addMockResponse(for url: URL, httpMethod: String, data: Data?, urlResponse: URLResponse?, error: NSError?) {
26        let response = (data: data, urlResponse: urlResponse, error: error)
27        let mockedResponseString = "\(httpMethod)||\(url.absoluteString)"
28        mockResponses[mockedResponseString] = response
29    }
30}

It’s pretty accurately named; it’s a class that inherits from URLSession (previously NSURLSession ). It overrides a few things from URLSession , namely the dataTask function and the shared property. Through dependency injection we are able to specify that we want our connection to use one of these MockSession objects to make any HTTP requests it needs to make.

We can then specify MockTask objects that we want to be returned by our MockSession for given requests. This allows us to not have to make actual HTTP requests when we run our tests and we can also specify exactly the behaviour we want to test with these requests: any sort of mix of success and failure cases.

1public class MockTask: URLSessionDataTask {
2    public var mockResponse: Response
3    public let completionHandler: ((Data?, URLResponse?, NSError?) -> Void)?
4
5    public init(response: Response, completionHandler: ((Data?, URLResponse?, NSError?) -> Void)?) {
6        self.mockResponse = response
7        self.completionHandler = completionHandler
8    }
9
10    override public func resume() {
11        DispatchQueue.global(qos: .default).async {
12            self.completionHandler!(self.mockResponse.data, self.mockResponse.urlResponse, self.mockResponse.error)
13        }
14    }
15}

This is just an example of how to create a mock URLSession object. Depending on your needs you might want to use something like OHHTTPStubs for your network request stubbing. However, for the PusherSwift use case creating our own lightweight mocks was all that was required.

There are also plenty of other objects that are mocked in the PusherSwift test suites. As you can imagine the underlying websocket is mocked when appropriate, as an example. If you’re interested in more details about the sorts of mocking that goes on in the PusherSwift library then take a look at this file.

Travis CI improvements

Not relying on any external dependencies has another added benefit. All of the Travis CI builds are now a good chunk quicker to run seeing as they no longer involve ensuring the Cocoapods gem is installed as well as then actually installing the required Cocoapods. However, making things run quicker doesn’t necessarily mean that they’ll succeed quicker; they’ll also fail quicker, which is thankfully not happening so much anymore.

Perhaps the most frustrating errors you can face as a developer using Xcode are when xcodebuild commands seem to randomly fail, especially when running on Travis. Nobody likes to get notified that a commit they’ve just pushed has caused the build to fail. It’s that much worse when you then see that all but one of the builds in the test matrix succeeded and the one that failed only failed because the device simulator apparently didn’t manage to launch properly.

To be clear, this is not me hating on Travis. I’m a massive Travis fan, even more so nowadays after having made some changes to PusherSwift’s travis.yml file that have made Travis runs pretty much rock solid. The gist of it is that you need give the build and test processes as much help and support as possible. You can achieve this by doing things like specifying the exact ID of the simulator device you’d like to boot for the tests you’re going to run. Specifically, I’ve ended up with this as part of the PusherSwift .travis.yml file:

1SIMULATOR_ID=$(xcrun instruments -s | grep -o "$DEVICE \[.*\]" | grep -o "\[.*\]" | sed "s/^\[\(.*\)\]$/\1/")

where DEVICE is set in the environment matrix at the top, and the values I’ve chosen for it in the case of tvOS and iOS builds are Apple TV 1080p (10.0) and iPhone 6s (10.0) respectively.

If you’re interested in more specifics then take a look at the YAML file and you should be able to crib some tips from my experience (or better yet, tell me how to improve it further).