Testing push notifications within XCTest
This Note originally published on my Personal Blog here. Read original note so that you won’t miss any content.
Xcode 11.4 introduced a handy feature that allowed us to test push notifications on Simulator (xcrun simctl push
). Unfortunately, it’s still not possible to take advantage of it within the XCTest framework. So, let’s build our own bike!
Tooling
I will use the following tools:
- XCTest — a native framework to write Unit and UI tests on iOS
- Sinatra — an awesome DSL for quickly creating web applications in Ruby
- Fastlane — the best tool for automating almost everything in and around iOS and Android platforms
Paths
Gemfile
— a file that defines your Ruby dependenciesfastlane/Fastfile
— a Fastlane configuration filefastlane/sinatra.rb
— a Sinatra appfastlane/sinatra_log.txt
— an output of the Sinatra serverfastlane/push_payload.json
— Apple Push Notification service (APNs) payload
Precondition
- We need to create a Gemfile:
source 'https://rubygems.org'gem 'fastlane'
gem 'sinatra'
- And install the required gems using bundler:
gem install bundler
bundle install
Web server configuration
There is no way to execute macOS commands from XCTest as it works in the sandbox on the device/simulator. To bypass this limitation, we gonna build a tiny web server that will listen for HTTP requests and run an xcrun
command for us as soon as it receives them. So let’s create a sinatra.rb
file with the following content:
require 'sinatra'post '/push/:udid/:bundle_id' do
push_data_file = 'push_payload.json'
File.open(push_data_file, 'w') { |f| f.write(request.body.read) }
puts `xcrun simctl push #{params['udid']} #{params['bundle_id']} #{push_data_file}`
end
This script will run the web server and listen for the POST requests on the /push/:udid/:bundle_id
endpoint. As soon as Sinatra receives a request, it will write the request body to the push_payload.json file and pass it to the xcrun
command. The :udid
and :bundle_id
parameters are going to be passed to the xcrun
command as well. The xcrun simctl push
is exactly the guy who sends a push notification to the specified device.
Test framework configuration
I guess you already have an Xcode project so let's open your *UITests.swift
file and add the following functions and variables:
- The function to send a request to the web server with the required details:
func pushNotification(title: String, body: String, badge: Int = 1) {
let notificationPayload = """
{
"aps": {
"badge": badge,
"alert": {
"title": title,
"body": body
}
}
}
""" let targetBundleId = String(XCUIApplication().description.split(separator: "'")[1])
let simulatorUdid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"]! let urlString = "http://localhost:4567/push/\(simulatorUdid)/\(targetBundleId)"
let url = URL(string: urlString)! var request = URLRequest(url: url)
request.httpMethod = EndpointMethod.post.rawValue
request.httpBody = notificationPayload.data(using: .utf8)
URLSession.shared.dataTask(with: request).resume()
}
- The variable to access a push notification banner:
var notificationBanner: XCUIElement {
XCUIApplication(bundleIdentifier: "com.apple.springboard")
.otherElements["Notification"]
.descendants(matching: .any)
.matching(NSPredicate(format: "label CONTAINS[c] ', now,'"))
.firstMatch
}
- The function to tap on a push notification banner:
func tapOnPushNotificationBanner() {
notificationBanner.tap()
}
- The function to test a push notification banner:
func assertPushNotification(title: String, body: String) {
let bannerText = notificationBanner.label
XCTAssertTrue(bannerText.contains(title), "'\(bannerText)' does not contain '\(title)'")
XCTAssertTrue(bannerText.contains(body), "'\(bannerText)' does not contain '\(body)'")
}
- The function to launch the target application:
func launchApp() {
XCUIApplication().launch()
}
- The function to go to background:
func goToBackground() {
XCUIDevice.shared.press(XCUIDevice.Button.home)
sleep(2)
}
- The test itself:
func testPushNotification() {
let notificationTitle = "Foo"
let notificationBody = "Bar" launchApp()
goToBackground()
pushNotification(title: notificationTitle, body: notificationBody)
assertPushNotification(title: notificationTitle, body: notificationBody)
}
At this point, you already have a working test case that sends a push notification to the target application and verifies if the banner is displayed. To check it out:
- Just start the Sinatra server in the Terminal:
bundle exec ruby sinatra.rb
- And run your test manually from Xcode
CLI configuration
Testing locally is fine, but we are here to automate things properly. So, let’s create a Fastfile with the following content:
- The lane to start the Sinatra server:
lane :start_sinatra do
sh('nohup bundle exec ruby sinatra.rb > sinatra_log.txt 2>&1 &')
end
- The lane to run the provided test plan on the iPhone 8 simulator (make sure to update your project details in the scan action)
lane :test_push_notification do
start_sinatrascan(
project: 'SampleApp.xcodeproj',
scheme: 'SampleAppScheme',
testplan: 'SampleAppTestPlan',
devices: ['iPhone 8']
)
end
- The lane and the method to always stop the Sinatra server after a Fastlane execution has completed:
lane :stop_sinatra do
sh('lsof -t -i:4567 | xargs kill -9')
endafter_all do |lane|
stop_sinatra if lane == :test_push_notification
end
To check it out just run the following command:
bundle exec fastlane test_push_notification