Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Notification Permission UI Tests on macOS

Short answer. Register an addUIInterruptionMonitor before launching the app, call requestAuthorization(options:), force XCTest to drain pending interruptions with a follow-up interaction on the host app, then poll getNotificationSettings through an XCTestExpectation until UNAuthorizationStatus matches your assertion. Keep screenshots as XCTAttachment with .keepAlways so Xcode retains them for passing runs. Apple references: Asking permission to use notifications and XCUIApplication.

For hardware, the recommendation below depends on whether you build and sign an Intel or arm64 target. Figment's Mac hosting line starts at a $29/month virtual Intel macOS instance and an M-series tier from $95/month (exact chip, RAM and disk are confirmed in the quote). If you need the Launch Agent, backups and monitoring wired up for you, the Managed Build Mac at $249/month includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support.

Where the Permission Dialog Actually Comes From

UNUserNotificationCenter.current().requestAuthorization(options:) causes the system notification daemon to present a modal owned by a system process, not your app. XCUITest can interact with it on macOS through addUIInterruptionMonitor, but only when the test runner has accessibility permission and when a user is logged in with an active window server session.

Two consequences follow. First, the test must run on a real logged-in macOS session; there is no supported macOS-app simulator (Xcode's simulator targets iOS, iPadOS, watchOS, tvOS and visionOS). Second, the monitor fires only when XCTest performs the next interaction on the host app, which is why a stray app.tap() after triggering the request is load-bearing in every working example.

The Figment $29 virtual Intel macOS instance runs macOS and exposes a desktop through the browser console, so it is a real logged-in session suitable for this test as long as your target is Intel or a universal/Intel-compatible build with Xcode 26 or earlier. For arm64-only device or Mac Catalyst targets, or for Xcode 27, choose the M-series tier.

Resetting Permission State Before Each Run

The dialog only appears while the status is .notDetermined. Reset with tccutil before launching:

#!/bin/bash
set -euo pipefail
BUNDLE_ID="${1:?Usage: $0 <bundle-id>}"
tccutil reset UserNotifications "$BUNDLE_ID" \
  || tccutil reset UserNotification "$BUNDLE_ID"
echo "Notification permission reset for $BUNDLE_ID"

The service name has varied between UserNotifications and UserNotification across macOS releases, and some releases route notification choices outside TCC entirely. Verify on your target OS by running the reset, re-launching the app, and confirming the dialog reappears. If it does not, reinstall the app bundle or ship from a clean image.

Handling the System Alert in XCUITest

import XCTest

final class NotificationPermissionTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
    }

    func testGrantNotificationPermission() throws {
        addUIInterruptionMonitor(withDescription: "Notification Permission Dialog") { alert in
            let shot = alert.screenshot()
            let attachment = XCTAttachment(screenshot: shot)
            attachment.name = "notification-permission-dialog"
            attachment.lifetime = .keepAlways
            self.add(attachment)

            let allow = alert.buttons["Allow"]
            guard allow.exists else { return false }
            allow.tap()
            return true
        }

        app.launch()
        app.buttons["Enable Notifications"].tap()
        app.activate() // force the monitor to drain
        let status = waitForAuthStatus(expected: .authorized)
        XCTAssertEqual(status, .authorized)
    }
}

app.activate() (or any follow-up tap on the host app) is what triggers XCTest to evaluate the registered interruption monitors. Without it, the handler can be skipped and the dialog times out as an implicit deny. Replace the example button identifier with your own project's control; this page does not guess a scheme name for your target.

Asserting UNAuthorizationStatus With a Real Expectation

import UserNotifications
import XCTest

@discardableResult
func waitForAuthStatus(
    expected: UNAuthorizationStatus,
    timeout: TimeInterval = 8.0
) -> UNAuthorizationStatus {
    final class Box: @unchecked Sendable { var value: UNAuthorizationStatus = .notDetermined }
    let box = Box()
    let done = XCTestExpectation(description: "authStatus == \(expected)")

    func poll() {
        UNUserNotificationCenter.current().getNotificationSettings { s in
            box.value = s.authorizationStatus
            if s.authorizationStatus == expected {
                done.fulfill()
            } else {
                DispatchQueue.global().asyncAfter(deadline: .now() + 0.5, execute: poll)
            }
        }
    }
    poll()
    let result = XCTWaiter().wait(for: [done], timeout: timeout)
    XCTAssertEqual(result, .completed,
        "Expected \(expected), observed \(box.value) after \(timeout)s")
    return box.value
}

Polling getNotificationSettings every 0.5 s avoids any sleep() on the test thread and surfaces a real failure message when the dialog was never dismissed. For the deny path, inject the same monitor but tap alert.buttons["Don't Allow"] and assert .denied.

Saving the Evidence as a Named Attachment

Attachments from passing tests are deleted by default, which quietly erases your QA evidence. Set the lifetime explicitly:

let shot = alert.screenshot()
let attachment = XCTAttachment(screenshot: shot)
attachment.name = "notification-permission-\(Int(Date().timeIntervalSince1970))"
attachment.lifetime = .keepAlways
self.add(attachment)

Everything lands in the .xcresult bundle and can be extracted with xcrun xcresulttool for upload to your CI's artifact store. If the test fails, do not re-run the suite and count the next green bar as the benchmark — the failure is the signal.

Running It on a Hosted Mac: A Short Worksheet

The test needs a logged-in GUI session, accessibility permission for the runner, and (for arm64 targets) Apple silicon. Pick the tier that matches the target, not the headline price:

Target you shipFigment plan to requestWhat it gets you
Intel or universal binary, Xcode 26 or earlierVirtual Intel macOS, $29/moBrowser desktop session, enough for the XCUITest above
arm64 device, Mac Catalyst arm64, or Xcode 27M-series, from $95/moApple silicon execution; confirm chip/RAM/disk in the quote
You want the Launch Agent, backups and monitoring set up for youManaged Build Mac, $249/moOne M-series Mac, CI setup, backups, monitoring, 30 minutes of monthly support
Two Macs and two CI runners for a teamManaged Mac Team, $999/moTeam setup, backups, monitoring, one hour of monthly support

Physical Intel ($49) and physical M-series offers are request-based while supply is confirmed. Pricing and inclusions above come from Figment's current Mac hosting page; taxes and fees are as stated in the quote you receive. If a competitor's quoted equivalent-hardware plan is cheaper for your exact spec, compare that line to the managed inclusions above rather than to the $29 entry tier, which is a lower entry cost, not equal hardware.

Request a configuration quote at /mac-hosting or begin the setup checklist at /mac-hosting/start.

Related guides


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms