What I Learned Building the PusherSwift Framework
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.
class PusherPresenceChannelSpec: QuickSpec {
override func spec() {
var pusher: Pusher!
var socket: MockWebSocket!
beforeEach({
let options = PusherClientOptions(
authMethod: .inline(secret: "secret")
)
pusher = Pusher(key: "key", options: options)
socket = MockWebSocket()
socket.delegate = pusher.connection
pusher.connection.socket = socket
})
describe("the members object") {
it("stores the socketId if no userDataFetcher is provided") {
pusher.connect()
let chan = pusher.subscribe("presence-channel") as? PresencePusherChannel
expect(chan?.members.first!.userId).to(equal("46123.486095"))
}
}
}
}
Now the same test written using XCTest looks like this:
class PusherPresenceChannelTests: XCTestCase {
var pusher: Pusher!
var socket: MockWebSocket!
var options: PusherClientOptions!
override func setUp() {
super.setUp()
options = PusherClientOptions(
authMethod: .inline(secret: "secret")
)
pusher = Pusher(key: "key", options: options)
socket = MockWebSocket()
socket.delegate = pusher.connection
pusher.connection.socket = socket
}
func testMembersObjectStoresSocketIdIfNoUserDataFetcherIsProvided() {
pusher.connect()
let chan = pusher.subscribe("presence-channel") as? PresencePusherChannel
XCTAssertEqual(chan?.members.first!.userId, "46123.486095", "the userId should be 46123.486095")
}
}
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.
class AuthenticationSpec: QuickSpec {
override func spec() {
var pusher: Pusher!
var socket: MockWebSocket!
beforeEach({
let options = PusherClientOptions(
authMethod: AuthMethod.endpoint(authEndpoint: "http://localhost:9292/pusher/auth")
)
pusher = Pusher(key: "testKey123", options: options)
socket = MockWebSocket()
socket.delegate = pusher.connection
pusher.connection.socket = socket
})
describe("subscribing to a private channel") {
it("should make a request to the authEndpoint") {
if case .endpoint(authEndpoint: let authEndpoint) = pusher.connection.options.authMethod {
setupMockAuthResponseForRequest(toEndpoint: authEndpoint)
}
let chan = pusher.subscribe("private-test-channel")
expect(chan.subscribed).to(beFalsy())
pusher.connect()
expect(chan.subscribed).toEventually(beTruthy())
}
}
}
}
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.
class AuthenticationTests: XCTestCase {
class DummyDelegate: PusherConnectionDelegate {
var ex: XCTestExpectation? = nil
var testingChannelName: String? = nil
func subscriptionDidSucceed(channelName: String) {
if let cName = testingChannelName, cName == channelName {
ex!.fulfill()
}
}
}
var pusher: Pusher!
var socket: MockWebSocket!
override func setUp() {
super.setUp()
let options = PusherClientOptions(
authMethod: AuthMethod.endpoint(authEndpoint: "http://localhost:9292/pusher/auth")
)
pusher = Pusher(key: "testKey123", options: options)
socket = MockWebSocket()
socket.delegate = pusher.connection
pusher.connection.socket = socket
}
func testSubscribingToAPrivateChannelShouldMakeARequestToTheAuthEndpoint() {
let ex = expectation(description: "the channel should be subscribed to successfully")
let channelName = "private-test-channel"
let dummyDelegate = DummyDelegate()
dummyDelegate.ex = ex
dummyDelegate.testingChannelName = channelName
pusher.connection.delegate = dummyDelegate
if case .endpoint(authEndpoint: let authEndpoint) = pusher.connection.options.authMethod {
setupMockAuthResponseForRequest(toEndpoint: authEndpoint)
}
let chan = pusher.subscribe(channelName)
XCTAssertFalse(chan.subscribed, "the channel should not be subscribed")
pusher.connect()
waitForExpectations(timeout: 0.5)
}
}
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:
let jsonData = "{\"auth\":\"testKey123:12345678gfder78ikjbg\"}".data(using: String.Encoding.utf8, allowLossyConversion: false)!
let urlResponse = HTTPURLResponse(url: URL(string: "\(authEndpoint)?channel_name=private-test-channel&socket_id=45481.3166671")!, statusCode: 200, httpVersion: nil, headerFields: nil)
MockSession.mockResponse = (jsonData, urlResponse: urlResponse, error: nil)
pusher.connection.URLSession = MockSession.shared
What is this MockSession
object, you might ask. It’s pretty simple really:
public typealias Response = (data: Data?, urlResponse: URLResponse?, error: NSError?)
public class MockSession: URLSession {
static public var mockResponses: [String: Response] = [:]
static public var mockResponse: (data: Data?, urlResponse: URLResponse?, error: NSError?) = (data: nil, urlResponse: nil, error: nil)
override public class var shared: URLSession {
get {
return MockSession()
}
}
override public func dataTask(with: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
var response: Response
let mockedMethodAndUrlString = "\(with.httpMethod!)||\((with.url?.absoluteString)!)"
if let mockedResponse = MockSession.mockResponses[mockedMethodAndUrlString] {
response = mockedResponse
} else {
response = MockSession.mockResponse
}
return MockTask(response: response, completionHandler: completionHandler)
}
public class func addMockResponse(for url: URL, httpMethod: String, data: Data?, urlResponse: URLResponse?, error: NSError?) {
let response = (data: data, urlResponse: urlResponse, error: error)
let mockedResponseString = "\(httpMethod)||\(url.absoluteString)"
mockResponses[mockedResponseString] = response
}
}
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.
public class MockTask: URLSessionDataTask {
public var mockResponse: Response
public let completionHandler: ((Data?, URLResponse?, NSError?) -> Void)?
public init(response: Response, completionHandler: ((Data?, URLResponse?, NSError?) -> Void)?) {
self.mockResponse = response
self.completionHandler = completionHandler
}
override public func resume() {
DispatchQueue.global(qos: .default).async {
self.completionHandler!(self.mockResponse.data, self.mockResponse.urlResponse, self.mockResponse.error)
}
}
}
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:
SIMULATOR_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).