MiniRent Engineering Guide

Deterministic iOS Permission Tests with simctl privacy

Deterministic iOS Permission Tests with simctl privacy

The same iOS UI tests may pass on a development machine but intermittently stall on a permission prompt after moving to a cloud Mac. This usually does not mean the application code is failing at random. Instead, the simulator has retained the TCC permission state from a previous job. If someone selected “Allow,” later tests will no longer see the first-request prompt. If someone selected “Don’t Allow,” flows that depend on contacts or photos will immediately take the failure path. Stable testing does not come from retrying more often, but from explicitly defining permission preconditions before every test.

Treat permission state as test input

Permission tests should cover at least three states: not determined, authorized, and denied. Each state corresponds to a different product path and should not be collapsed into a single “reinstall the app” step.

Target state simctl action Expected behavior
Not determined reset The app requests access and the system displays a permission prompt
Authorized grant The feature proceeds immediately without asking again
Denied revoke The app displays fallback information or guidance to Settings

First confirm that the runtime supports the target service. The controllable services can differ between system versions, so do not hard-code a service name based on experience and commit it without checking:

xcrun simctl privacy help
xcrun simctl list devices available

Common services include contacts, photos, photos-add, location, microphone, and calendar. Test scripts should follow the help output from the version of simctl bundled with the current Xcode installation.

Permission state belongs to the simulator device, not the code repository. Cleaning the build directory does not remove TCC data, and deleting and reinstalling the app should not be treated as a reliable way to reset permissions.

Prepare a dedicated simulator and app

CI jobs should not casually use booted to target any running device. Parallel jobs may resolve it to the same simulator, causing installations, launches, and permission changes to overwrite one another. A safer approach is to assign each job a specific UDID and pass it to the script as an argument.

The app must be installed before grant or revoke can operate on its actual Bundle Identifier. Do not infer the Bundle Identifier from the Scheme name; read it directly from the build product:

APP_PATH="$1"
UDID="$2"
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
  "$APP_PATH/Info.plist")

xcrun simctl boot "$UDID" 2>/dev/null || :
xcrun simctl bootstatus "$UDID" -b
xcrun simctl install "$UDID" "$APP_PATH"
printf '%s
' "$BUNDLE_ID"

When testing an extension, remember that it has its own Bundle Identifier. Read the extension’s Info.plist separately instead of using the main app identifier for every permission command.

Set the three precondition states in a script

Centralizing state changes in one function makes them easier to review than scattering them across pipeline configuration. Terminate the app before changing permissions so the process does not retain an old state or remain open with a prompt on screen.

set -euo pipefail

UDID="$1"
BUNDLE_ID="$2"
SERVICE="$3"
STATE="$4"

xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || :

case "$STATE" in
  undecided)
    xcrun simctl privacy "$UDID" reset "$SERVICE" "$BUNDLE_ID"
    ;;
  authorized)
    xcrun simctl privacy "$UDID" grant "$SERVICE" "$BUNDLE_ID"
    ;;
  denied)
    xcrun simctl privacy "$UDID" revoke "$SERVICE" "$BUNDLE_ID"
    ;;
  *)
    printf 'Unknown permission state: %s
' "$STATE" >&2
    exit 64
    ;;
esac

Launch the app or run the tests only after the command completes. For example, an authorized contacts scenario can call the script first and then run only the relevant test class:

./set-permission.sh "$UDID" "$BUNDLE_ID" contacts authorized

xcodebuild test \
  -scheme PermissionTests \
  -destination "platform=iOS Simulator,id=$UDID" \
  -only-testing:PermissionUITests/ContactsAuthorizedTests \
  -resultBundlePath Artifacts/ContactsAuthorized.xcresult

reset returns the permission to the not-determined state; it does not deny access. If the application code handles these two states as if they were identical, the test should expose that design problem directly.

Test system prompts separately from application branches

System permission prompts are part of the system UI, so button lookup can be affected by language, runtime version, and prompt order. Do not make every permission test depend on interacting with a prompt. A better test structure uses grant and revoke for most application-branch coverage while retaining only a small number of tests for the initial request.

First-request cases

Run reset first, launch the app, and trigger the permission request. The test should only verify that the prompt appears and that the app reaches the expected screen after the user makes a choice. If interaction with system UI is required, use a separate system application object and avoid relying on fixed coordinates.

Authorized and denied cases

Neither type of test should see a prompt. The authorized state verifies that the feature can read data normally. The denied state verifies that the app does not repeatedly request access or remain stuck in an indefinite loading state. If the app provides guidance to Settings, verify only the button and explanatory text; do not actually modify system settings in the automated job.

Location permissions also require distinguishing business semantics such as authorization while using the app and continuous authorization. simctl privacy can prepare the basic state, but it cannot reproduce every real-device behavior. Related conclusions should therefore still be validated on physical devices.

Isolation, evidence collection, and failure diagnosis

You can run reset for the relevant service after each test class, but it is more important for the next test to establish its own state rather than depend on the previous test cleaning up successfully. During parallel execution, assign each simulator to only one job. If concurrency is required, create multiple devices instead of sharing a UDID.

On failure, preserve at least the .xcresult, test logs, simulator system logs, and the current device list. If the expected state was not applied, check the following in order:

  1. Whether the Bundle Identifier came from the app or extension that was actually installed.
  2. Whether the current runtime supports the service name.
  3. Whether the app was terminated before the state was changed.
  4. Whether the target UDID matches the device used by xcodebuild.
  5. Whether another parallel job is operating on the same simulator.
  6. Whether the test code cached an old authorization result.

Do not make erasing the entire simulator the default fix. It significantly increases job duration and can hide state-management defects. Recreate the device only when it continues to exhibit an unexplained system state, and retain the preceding logs.

When running these jobs on MiniRent cloud Macs, the model and node affect only job placement. The test script should still describe the environment completely through the UDID, Bundle Identifier, service name, and target state, while the currently available configurations should be confirmed in the console. This allows the same permission regression suite to be reproduced in temporary debugging jobs and run reliably in continuous integration.

Frequently asked questions

Can simctl privacy replace every permission prompt test?

No. It is best for preparing deterministic states. Keep a small end-to-end UI suite to verify the actual system prompt, button flow, and return path.

Must the Simulator be erased before every permission test?

Usually not. Terminate the app and reset, grant, or revoke the relevant service for its bundle identifier. Recreate the device only when its broader state is unreliable.

Dedicated physical device

Run your next development or build task on a cloud Mac mini

Choose from two M4 configurations, four rental periods, and five available locations. Actual availability is subject to the real-time status returned by the console.

Choose a configuration and rent now