Verifying tracking events in XCUITest

Rafał Kwiatkowski
Fandom Engineering
Published in
5 min readNov 20, 2020
Photo by Lukas Blazek on Unsplash

UI tests are a great way to verify if your application is working correctly. Using them, you can verify if certain actions cause certain effects. However, you usually don’t want your application to send any data to production services, especially when the application is doing requests that modify the data on the server. In case when we know what request and response bodies should look like, it’s not a big problem. We can mock all the requests that the application makes and stub the responses.

However, it’s harder if you want to mock requests that are sent to analytics, which is very often handled by some third-party provider like Google Firebase. Doing it the same way would require you to intercept requests sent to the third-party provider to make sure that correct data is sent. It’s not a pretty solution, especially that in new versions of third-party SDK, request and response format may change.

In this article, I’d like to propose a solution that lets you intercept tracking events triggered by your application without sending them to the server. It will give you a decent way of verifying if certain events are triggered by certain actions, with all the benefits of Swift's strong typing.

Using dependency injection to inject a tracker

In this solution, I’d like to show a way of using dependency injection, so we can inject an object responsible for sending tracking events to the app. This way we are able to inject different implementations in production and test applications. The production implementation would send events to a real analytics provider. For UI tests, however, we set up a local server that will receive the events triggered by UI test actions and inject an object that will serialize and send tracking events to this server

We use a client-server approach because in XCUITest it’s impossible to directly access the code of the target application, so we couldn’t verify if events were recorded in a local array in the app. That’s why we need a way of communicating between these two separate targets and a local HTTP server seems to be a decent solution, especially when we have the right tools to implement it.

Let’s consider a sample application that you can find in the following GitHub repository: https://github.com/Wikia/ios-uitests-example. It’s a single view application with one view controller having 2 buttons. The view controller looks like follows:

class ViewController: UIViewController {

var tracker: TrackerProtocol?

// ...

@IBAction func button1Tapped(_ sender: Any) {
tracker?.trackEvent(.init(name: "button_tapped", params: [
.eventName: "button1_tapped",
.screen: "main_screen",
.custom(key: "screen_orientation"): view.frame.size.orientation
]))
}

// …
}

As you can see, the view controller has a tracker object of TrackerProtocol type:

protocol TrackerProtocol {
func trackEvent(_ event: TrackingEvent)
}

When the button is tapped, a tracking event is sent to this object. The tracking event is defined as a struct:

struct TrackingEvent: Codable, Equatable {
let name: String
let params: [TrackingParam: AnyHashable]
}

It has a name and allows you to pass a dictionary of params, with TrackingParam type as key type:

enum TrackingParam: Hashable, CodingKey {
case eventName
case screen
case custom(key: String)
}

As you can see, it has some predefined param names, like eventName and screen, but also lets you define any custom param name with .custom(key:) case.

Tracking event conforms to Codable protocol, so it can be encoded and decoded to data representation and sent to our local server. In order to make it possible, TrackingParam needs to conform to the CodingKey protocol. You can find the implementation of this conformance in the sample application. It also conforms to Equatable protocol, so we can easily verify if a certain event was triggered by our application.

In order to inject a proper version of the tracker, we define separate app delegates for production and test applications. In the sample application, the production tracker will just print received events (in the real application, it would send the events for example to Firebase). In the test app, we define a TrackerDouble class that serializes events and sends them to the local server:

class TrackerDouble: TrackerProtocol {

private static let serverURL: URL! = URL(string: "http:/[::1]:8080")

private let jsonEncoder = JSONEncoder()

func trackEvent(_ event: TrackingEvent) {
var request = URLRequest(url: TrackerDouble.serverURL)
request.httpMethod = "POST"
request.httpBody = try? jsonEncoder.encode(event)
URLSession.shared.dataTask(with: request).resume()
}
}

Intercepting tracking events in UI tests

We set up a local server in the UI tests target using Embassy lib. This part was inspired by the https://envoy.engineering/embedded-web-server-for-ios-ui-testing-8ff3cef513df article, where it’s described how to set up a web server in UI tests.

However, in our case, it’s much simpler, as it’s used only for tracking events. We defined a TrackingVerifier class which starts the server, listens to tracking events that are uploaded to it, deserializes them to TrackingEvent structs, and saves them to a local array:

import XCTest
import Embassy

class TrackingVerifier {
private let port = 8080
private var eventLoop: EventLoop!
private var server: HTTPServer!
private var eventLoopThreadCondition: NSCondition!
private var eventLoopThread: Thread!
private var events: [TrackingEvent] = []
private let jsonDecoder = JSONDecoder()

private unowned var testCase: XCTestCase!

init(with testCase: XCTestCase) {
self.testCase = testCase
}

func start() {
eventLoop = try! SelectorEventLoop(selector: try! KqueueSelector())
server = DefaultHTTPServer(eventLoop: eventLoop, port: port) { [weak self] environ, startResponse, _ in
let input = environ["swsgi.input"] as! SWSGIInput
input { data in
guard let self = self,
let event = try? self.jsonDecoder.decode(TrackingEvent.self, from: data) else { return }
self.events.append(event)
}

startResponse("200 OK", [])
}
// Start HTTP server to listen on the port
try! server.start()

eventLoopThreadCondition = NSCondition()
eventLoopThread = Thread(target: self, selector: #selector(runEventLoop), object: nil)
eventLoopThread.start()

}

func stop() {
server.stopAndWait()
eventLoopThreadCondition.lock()
eventLoop.stop()
while eventLoop.running {
if !eventLoopThreadCondition.wait(until: Date().addingTimeInterval(10)) {
fatalError("Join eventLoopThread timeout")
}
}

events = []
}

@objc private func runEventLoop() {
eventLoop.runForever()
eventLoopThreadCondition.lock()
eventLoopThreadCondition.signal()
eventLoopThreadCondition.unlock()
}

func wait(for event: TrackingEvent) {
let predicate = NSPredicate { [weak self] _, _ in
self?.events.contains(event) == true
}
let expectation = testCase.expectation(for: predicate, evaluatedWith: self, handler: nil)
testCase.wait(for: [expectation], timeout: 5.0)
}
}

The only thing left is to use the TrackingVerifier class in the UI tests:

class UITestsExampleUITests: XCTestCase {

private lazy var trackingVerifier = TrackingVerifier(with: self)

override func setUp() {
super.setUp()
continueAfterFailure = false
trackingVerifier.start()
}

override func tearDown() {
trackingVerifier.stop()
super.tearDown()
}

func testTapButton1() throws {
// Given
let app = XCUIApplication()
app.launch()
trackingVerifier.start()
let expectedEvent = TrackingEvent(name: "button_tapped", params: [
.eventName: "button1_tapped",
.screen: "main_screen",
.custom(key: "screen_orientation"): "portrait"
])

// When
app.buttons["Button 1"].tap()

// Then
trackingVerifier.wait(for: expectedEvent)
}

// …
}

As you can see, we define the expected event using the TrackingEvent type and use trackingVerifier to verify that it was fired.

Summary

Using a local server is a really convenient way to provide communication between two separate targets — application and UI tests. Thanks to it we could automate testing tracking events fired by actions in the application. As a consequence of implementing Codable protocol by data structs sent to and received by the server, we can benefit from Swift strong typing and make sure that everything is sent with the proper types.

Originally published at https://dev.fandom.com.

--

--