
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.
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.
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.
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.
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.
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.
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 ship | Figment plan to request | What it gets you |
|---|---|---|
| Intel or universal binary, Xcode 26 or earlier | Virtual Intel macOS, $29/mo | Browser desktop session, enough for the XCUITest above |
| arm64 device, Mac Catalyst arm64, or Xcode 27 | M-series, from $95/mo | Apple silicon execution; confirm chip/RAM/disk in the quote |
| You want the Launch Agent, backups and monitoring set up for you | Managed Build Mac, $249/mo | One M-series Mac, CI setup, backups, monitoring, 30 minutes of monthly support |
| Two Macs and two CI runners for a team | Managed Mac Team, $999/mo | Team 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