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 yourRubydependenciesfastlane/Fastfile- aFastlaneconfiguration filefastlane/sinatra.rb- aSinatraappfastlane/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
gemsusingbundler: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
Sinatraserver 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
Sinatraserver: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 8simulator (make sure to update your project details in thescanaction)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
Sinatraserver after aFastlaneexecution 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