Testing push notifications within XCTest
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 yourRuby
dependenciesfastlane/Fastfile
- aFastlane
configuration filefastlane/sinatra.rb
- aSinatra
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
usingbundler
: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’t 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. See the complete code example here. To check it out:
-
Just start the
Sinatra
server in theTerminal
: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 thescan
action)lane :test_push_notification do start_sinatra scan( project: 'SampleApp.xcodeproj', scheme: 'SampleAppScheme', testplan: 'SampleAppTestPlan', devices: ['iPhone 8'] ) end
-
The lane and the method to always stop the
Sinatra
server after aFastlane
execution has completed:lane :stop_sinatra do sh('lsof -t -i:4567 | xargs kill -9') end after_all do |lane| stop_sinatra if lane == :test_push_notification end
See the complete code example here. To check it out just run the following command:
bundle exec fastlane test_push_notification