3 minute read

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 your Ruby dependencies
  • fastlane/Fastfile - a Fastlane configuration file
  • fastlane/sinatra.rb - a Sinatra app
  • fastlane/sinatra_log.txt - an output of the Sinatra server
  • fastlane/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’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 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_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 a Fastlane 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

Updated: