How to Test Permission Dialogs on React Native (Complete Guide)

How to Test Permission Dialogs on React Native (Complete Guide)

June 13, 2026 · 18 min read · How-To Guides

How to Test Permission Dialogs on React Native (Complete Guide)

Testing permission dialogs is a critical part of ensuring that a React Native app behaves correctly when it accesses device capabilities such as the camera, microphone, location, or contacts. If the dialog handling is flawed, users may encounter crashes, silent failures, or unexpected UI states that erode trust and lead to negative reviews. This guide walks you through why permission dialogs matter, provides a comprehensive test matrix, shows manual and automated techniques, shares concrete code snippets, and explains how autonomous, persona‑driven exploration can surface bugs that scripted tests often miss.

How to Test Permission Dialogs on React Native (Complete Guide): Why It Matters

Permission dialogs are the gatekeepers between your app and sensitive device resources. On both Android and iOS, the operating system presents a modal that asks the user to grant or deny access. When your React Native code calls a permission‑requesting API—such as PermissionsAndroid.request or the expo‑permissions wrapper—the native bridge shows this dialog, and your JavaScript layer receives a callback with the result. If you assume the call always succeeds, you risk trying to read from a camera that the user blocked, which can throw exceptions or return empty data. Moreover, Android 13 introduced a new runtime permission model for near‑by Wi‑Fi devices, and iOS 17 added provisional authorization for location, meaning the same code path can produce different outcomes depending on OS version. In production, missing or mishandled dialog responses have led to crashes that appear only after a user upgrades their OS, to privacy complaints when an app continues to request a permission after a “Never ask again” selection, and to accessibility failures when the dialog is not announced correctly by screen readers. By systematically testing every possible user interaction with these dialogs, you catch those issues before they reach users and you build confidence that your app respects user choices and platform guidelines.

How to Test Permission Dialogs on React Native (Complete Guide): Test Matrix

A solid test matrix covers the happy path, error paths, edge cases, accessibility considerations, and security/privacy implications. Below is a table that enumerates the scenarios you should verify for each permission type (camera, microphone, location, contacts, notifications, etc.). Mark each cell with ✅ when the scenario passes and ❌ when it fails; use the table as a living checklist during manual or automated runs.

Permission TypeScenarioExpected BehaviorTest Notes
CameraUser taps AllowCamera stream starts, UI updates accordinglyVerify that the preview renders and no error is thrown
CameraUser taps DenyApp shows a fallback UI or disables camera‑dependent featuresEnsure no crash and that the app does not repeatedly request
CameraUser selects Never ask again (Android) or Don’t Allow (iOS)Subsequent requests return immediately denied without showing dialogCheck that the permission status is persisted across app restarts
CameraDialog appears while another modal is open (e.g., alert)OS stacks dialogs; app receives result after the modal dismissesTest on Android API 30+ where dialogs can be overlapped
CameraRequest made from a background service or headless JSOn Android, request is postponed until foreground; on iOS, request failsValidate that your code handles the postponed case gracefully
CameraUser changes permission via Settings while app is runningNext request reflects the new setting without needing a restartUse adb shell cmd or Settings app to toggle and observe
CameraAccessibility: TalkBack/VoiceOver reads the dialogAnnounces title, options, and default action correctlyVerify with accessibility scanner and manual listening
CameraDeny then quickly re‑request (rapid‑tap)Second request shows dialog again (unless Never ask again)Ensure no UI glitch or duplicate dialog
Location (Android 13+)Request for ACCESS_FINE_LOCATION while ACCESS_COARSE_LOCATION already grantedSystem may upgrade to fine location if user consentsTest both upgrade and downgrade paths
Location (iOS 17+)Request for WhenInUse triggers provisional authorizationApp receives limited accuracy until user upgrades to fullConfirm that your app degrades gracefully
NotificationsUser taps AllowApp receives device token and can register with push serviceCheck token receipt and backend registration
NotificationsUser taps Don’t AllowSubsequent requests return denied immediatelyEnsure no loop of request calls
ContactsUser grants access, then revokes via Settings mid‑sessionNext read returns empty array; no crashVerify that your code checks status before each read
Multiple permissions (camera + microphone)User allows one, denies the otherApp receives partial grant object; features depending on denied permission are disabledTest that UI reflects mixed state correctly
Permission rationale missing (Android)Request without providing a rationale when required (targetSdk >= 29)System may show a rationale dialog automatically; app may appear confusingEnsure you pass a rationale string or handle missing gracefully
Security/privacyApp logs permission results to analytics without maskingPotential leakage of denial patternsReview analytics payloads for PII

Happy Path Validation

The happy path confirms that when a user grants permission, the intended functionality works without error. For each permission, invoke the request API, assert that the callback returns a granted status, and then execute the feature that depends on that permission (e.g., start camera preview, read location, open contacts picker). Record any latency between the dialog dismissal and the feature activation; excessive delays can indicate a race condition where the native module hasn’t yet updated its internal state.

Denial and “Never Ask Again” Paths

When a user denies a permission, your app should degrade gracefully. This means hiding UI elements that require the permission, showing an informative message, and avoiding repeated requests that could annoy the user. The “Never ask again” (Android) or “Don’t Allow” (iOS) selection is especially important because subsequent calls to the permission API return immediately with a denied status, bypassing the dialog. Your test must verify that the app does not enter an infinite request loop and that any cached permission state is updated correctly.

Edge Cases Involving System State

Permissions can be altered while your app is in the background, or while another modal is displayed. Test scenarios where the user opens Settings, toggles a permission, and then returns to your app. Also test situations where a system alert (e.g., low‑battery warning) appears over the permission dialog; the OS will stack the dialogs, and your app should still receive the correct result once the user interacts with both. On Android, be aware of the new “approximate location” permission introduced in Android 12, which can be granted alongside or instead of fine location.

Accessibility and Localization

Accessibility testing ensures that TalkBack (Android) and VoiceOver (iOS) announce the dialog title, the two action buttons, and any explanatory text. Verify that the spoken language matches the app’s localization and that the focus order is logical. Additionally, check that the dialog respects dynamic type settings; on iOS, the system may increase the button size, and on Android, the dialog should scale with font size adjustments.

Security and Privacy Considerations

From a security standpoint, ensure that your app does not inadvertently grant broader access than requested. For example, requesting camera access should not also enable microphone access unless you explicitly ask for it. Review any third‑party libraries that bundle permission requests; they may ask for more than your code intends. From a privacy perspective, avoid logging denial reasons to external analytics without user consent, as this can reveal sensitive behavior patterns.

How to Test Permission Dialogs on React Native (Complete Guide): Manual Approach

Manual testing remains valuable for exploratory checks, especially when you want to observe the exact look and feel of the dialog under different device configurations. The steps below outline a reproducible manual workflow that you can adapt for each permission type.

Setting Up the Test Environment

  1. Device or Emulator Selection – Use a physical device whenever possible because system dialogs can behave differently on emulators (e.g., some Android emulators do not show the “Never ask again” checkbox). Keep a matrix of devices covering Android API levels 21‑34 and iOS versions 13‑17.
  2. Clear Permission State – Before each test, reset the permission to its default state. On Android, run adb shell pm reset-permissions ; on iOS, delete the app from the simulator or device and reinstall, or go to Settings → Your App → Permissions and toggle off.
  3. Instrument the App – Add a temporary debug screen that lists all permissions you intend to test and provides a button to request each one. This screen should also display the current permission status (granted, denied, never ask again) after each request, which you can obtain via the Permissions API or expo-permissions.
  4. Enable Accessibility Tools – Turn on TalkBack or VoiceOver, and optionally use an accessibility scanner (e.g., Android Accessibility Test Framework or Xcode’s Accessibility Inspector) to capture announcements.

Step‑by‑Step Manual Test Procedure

For each permission type, repeat the following steps:

  1. Launch the app and navigate to the debug permission screen.
  2. Press the request button for the target permission. Observe the system dialog that appears.
  3. Record the dialog’s visual properties – title, message, button labels, and any icons.
  4. Interact with the dialog – tap Allow, Deny, or (on Android) the “Never ask again” checkbox followed by Deny/Allow as appropriate.
  5. Check the app’s immediate response – verify that the status displayed on the debug screen updates correctly and that any feature‑specific UI changes occur as expected.
  6. Test the “Never ask again” path – after denying with the checkbox, press the request button again; the dialog should not appear, and the status should remain denied.
  7. Simulate a permission change via Settings – exit the app, open Settings, toggle the permission, return to the app, and press the request button again. Confirm that the app reflects the new setting without a restart.
  8. Accessibility check – with TalkBack/VoiceOver enabled, listen to the announcements when the dialog appears. Ensure that the purpose of the permission and the two options are clearly conveyed.
  9. Repeat for edge cases – launch a system alert (e.g., incoming call or low‑battery warning) before pressing the request button, then verify that the dialog still appears and the result is captured correctly.

Manual Testing Checklist (Markdown Table)

Checklist ItemDescriptionPass/Fail
Permission resetApp starts with permission in default state
Dialog appearsSystem modal shows after request call
Correct title/messageMatches the rationale string you provided
Allow actionFeature works, status updates to granted
Deny actionFeature disabled, status updates to denied
Never ask again (Android)Subsequent requests skip dialog, status stays denied
iOS Don’t AllowSame as above
Settings toggle respectedChanging permission in Settings updates app without restart
Accessibility announcedTalkBack/VoiceOver reads title and options
Overlapping modal handledDialog appears above other alerts, result still captured
Background permission changePermission altered while app in background, next request reflects change
No request loopRepeated requests after denial do not spam dialogs
Localization respectedDialog text matches app’s current locale
Dynamic type scalingButtons and text scale with system font size

Perform this checklist for each permission you support. Any failed item indicates a defect that should be logged and prioritized.

How to Test Permission Dialogs on React Native (Complete Guide): Automated Testing with Detox

Detox is a popular end‑to‑end testing framework for React Native that works well with native dialogs because it runs on the actual device or emulator and can interact with system alerts. The key to testing permission dialogs with Detox is to use the device API to press buttons on the native alert that the OS presents.

Detox Setup Overview

  1. Add Detox to your projectyarn add --dev detox and detox init -r jest.
  2. Configure a test device – In detox.config.js, specify an Android emulator or iOS simulator that matches the OS version you want to test. Ensure that the binaryPath points to your built app (use gradlew assembleDebug or xcodebuild).
  3. Grant test permissions to the test runner – On Android, you may need to add and ensure that the test instrumentation has access to the android.permission.ACCESS_MOCK_LOCATION if you plan to mock location.
  4. Disable animation – For faster and more reliable tests, turn off window and animator scales via adb shell settings put global window_animation_scale 0 & adb shell settings put global transition_animation_scale 0 & adb shell settings put global animator_duration_scale 0.

Writing a Permission Test

Below is a sample Detox test that verifies the camera permission flow on Android. The test assumes you have a screen called PermissionScreen with a button requestCamera and a text element cameraStatus that shows “granted”, “denied”, or “neverAskAgain”.


// e2e/cameraPermission.test.js
describe('Camera Permission Flow', () => {
  beforeEach(async () => {
    await device.launchApp({ newInstance: true, permissions: {} }); // start with clean permissions
  });

  it('should grant camera access when user taps Allow', async () => {
    await element(by.id('requestCamera')).tap();
    // Detect the system alert and press Allow
    await waitFor(element(by.text('Allow'))).toBeVisible().withTimeout(5000);
    await element(by.text('Allow')).tap();

    // Verify status updates
    await waitFor(element(by.id('cameraStatus'))).toHaveText('granted').withTimeout(5000);
    // Optionally, start preview and check that a video element appears
    await expect(element(by.id('cameraPreview'))).toBeVisible();
  });

  it('should respect Deny and show fallback UI', async () => {
    await element(by.id('requestCamera')).tap();
    await waitFor(element(by.text('Deny'))).toBeVisible().withTimeout(5000);
    await element(by.text('Deny')).tap();

    await waitFor(element(by.id('cameraStatus'))).toHaveText('denied').withTimeout(5000);
    await expect(element(by.id('cameraPreview'))).toNotExist();
    await expect(element(by.id('cameraDisabledMessage'))).toBeVisible();
  });

  it('should handle Never ask again correctly', async () => {
    await element(by.id('requestCamera')).tap();
    // On Android, the Never ask again checkbox appears only after a prior denial
    await element(by.text('Deny')).tap(); // first denial to trigger checkbox
    await element(by.id('requestCamera')).tap(); // second request shows checkbox
    await waitFor(element(by.id('neverAskAgainCheckbox'))).toBeVisible().withTimeout(5000);
    await element(by.id('neverAskAgainCheckbox')).tap(); // check it
    await element(by.text('Deny')).tap(); // deny with checkbox

    // Third request should not show dialog
    await element(by.id('requestCamera')).tap();
    await expect(element(by.text('Allow'))).toNotExist(); // dialog absent
    await waitFor(element(by.id('cameraStatus'))).toHaveText('neverAskAgain').withTimeout(5000);
  });
});

#### Key Points in the Detox Example

Handling iOS Permission Dialogs

On iOS, the system alert does not contain a “Never ask again” checkbox; instead, a second denial results in the system setting the permission to denied permanently. The same test pattern applies, but you omit the checkbox step. Additionally, iOS may present a provisional authorization dialog for location; you can treat it as a regular alert with options “Allow While Using App” and “Don’t Allow”.

Dealing with Flaky Tests

Permission dialogs can be flaky if the test races with the native animation. To mitigate:

Integrating Detox into CI

Add a step to your CI pipeline that runs detox test --configuration android.emu.debug or detox test --configuration ios.sim.debug. Most CI services (GitHub Actions, Bitrise, CircleCI) provide Android emulators and iOS simulators; just ensure you have the required Xcode and Android SDK versions installed.

How to Test Permission Dialogs on React Native (Complete Guide): Automated Testing with React Native Testing Library and Jest

For unit‑level verification where you want to mock the native permission layer, React Native Testing Library (RNTL) combined with Jest is effective. This approach lets you test the logic that follows a permission request without invoking the actual system dialog, making tests fast and deterministic.

Mocking the Permissions API

If you are using react-native-permissions, you can mock its functions in a Jest setup file:


// jest.setup.js
jest.mock('react-native-permissions', () => ({
  check: jest.fn(),
  request: jest.fn(),
  PERMISSIONS: {
    CAMERA: 'camera',
    LOCATION: 'location',
  },
}));

Then, in your test, you control what request returns:


// __tests__/PermissionLogic.test.js
import { request, PERMISSIONS } from 'react-native-permissions';
import { requestCameraPermission } from '../src/permissionHelpers';

describe('Permission logic unit tests', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  it('calls request and proceeds on granted', async () => {
    request.mockResolvedValueOnce('granted');
    const result = await requestCameraPermission();
    expect(request).toHaveBeenCalledWith(PERMISSIONS.CAMERA);
    expect(result).toBe('granted');
  });

  it('handles denied status and returns fallback', async () => {
    request.mockResolvedValueOnce('denied');
    const result = await requestCameraPermission();
    expect(result).toBe('denied');
    // assert that your helper returns a fallback object or throws a custom error
  });

  it('treats blocked status as permanent denial', async () => {
    request.mockResolvedValueOnce('blocked'); // Android term for never ask again
    const result = await requestCameraPermission();
    expect(result).toBe('blocked');
  });
});

Testing Component Reactions

When your component renders different UI based on permission status, you can mock the hook that fetches the status. Suppose you have a custom hook usePermission that returns { status: 'granted' | 'denied' | 'blocked' }. In the test, you mock its return value:


// __tests__/CameraComponent.test.js
import { render, screen } from '@testing-library/react-native';
import CameraComponent from '../src/CameraComponent';
import { usePermission } from '../src/hooks/usePermission';

jest.mock('../src/hooks/usePermission');

describe('CameraComponent UI', () => {
  beforeEach(() => {
    usePermission.mockReset();
  });

  it('shows preview when permission granted', async () => {
    usePermission.mockReturnValueOnce({ status: 'granted' });
    render(<CameraComponent />);
    expect(await screen.findByTestId('camera-preview')).toBeVisible();
    expect(screen.getByTestId('permission-denied-message')).not.toBeInTheDocument();
  });

  it('shows request button when denied', async () => {
    usePermission.mockReturnValueOnce({ status: 'denied' });
    render(<CameraComponent />);
    expect(screen.getByTestId('request-permission-button')).toBeVisible();
    expect(screen.queryByTestId('camera-preview')).not.toBeInTheDocument();
  });

  it('shows permanent denial message when blocked', async () => {
    usePermission.mockReturnValueOnce({ status: 'blocked' });
    render(<CameraComponent />);
    expect(screen.getByTestId('permission-blocked-message')).toBeVisible();
    expect(screen.queryByTestId('request-permission-button')).not.toBeInTheDocument();
  });
});

Advantages and Limitations

Setting Up Jest for React Native

If you haven’t already, add the following to your package.json:


{
  "jest": {
    "preset": "react-native",
    "setupFilesAfterEnv": ["<rootDir>/jest.setup.js"],
    "testMatch": ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"]
  }
}

Run tests with yarn test or npm test.

How to Test Permission Dialogs on React Native (Complete Guide): Using Expo Permissions API

Expo simplifies permission handling by providing a unified expo-permissions module that works across managed and bare workflows. Testing with Expo follows similar patterns, but there are a few nuances due to the Expo Go app and the EAS build process.

Testing in Expo Go

When you run your app via Expo Go, the permission dialogs are still native; however, the Expo client adds an extra layer that may affect timing. To test:

  1. Launch Expo Go on a device or emulator.
  2. Open the developer menu (shake device or press Cmd+D in simulator) and select “Reload” to ensure a clean state.
  3. Use the Permissions module to check and request permissions. Example snippet:

import * as Permissions from 'expo-permissions';
import { useState } from 'react';

export default function PermissionTester() {
  const [status, setStatus] = useState(null);

  const askCamera = async () => {
    const { status: camStatus } = await Permissions.askAsync(Permissions.CAMERA);
    setStatus(camStatus);
  };

  return (
    <View>
      <Button title="Ask Camera" onPress={askCamera} />
      <Text>Current status: {status ?? 'unknown'}</Text>
    </View>
  );
}

You can manually tap the button and observe the dialog. To automate, you can use Detox with Expo as described earlier; the only difference is that the binary you build is the Expo client with your bundled JS.

EAS Build and Automated Cloud Testing

Expo’s Application Services (EAS) allow you to create a standalone binary (.apk or .ipa) that you can upload to services like Firebase Test Lab or Bitrise. In those environments, you can run Detox or XCTest/XCUITest scripts just as with a bare React Native app.

A typical EAS workflow for permission testing:


# 1. Build a debug binary for Android
eas build --platform android --profile debug

# 2. Download the artifact after build completes
# 3. Upload to Firebase Test Lab
gcloud firebase test android run \
  --type instrumentation \
  --app path/to/app-debug.apk \
  --test path/to/detox-test.apk \
  --device model=Pixel3,version=30,locale=en,orientation=portrait

# 4. Retrieve test results from the Firebase console

When using EAS, ensure that your eas.json includes a debug profile with developmentClient: true if you intend to use Detox, because Detox requires the ability to inject its test server into the binary.

Permissions Specific to Expo

Expo provides a few permission types that are not directly available in the core React Native APIs, such as Permissions.NOTIFICATIONS (iOS/Android) and Permissions.SYSTEM_ALERT_WINDOW (Android only). Test these the same way, but keep in mind that some permissions (like SYSTEM_ALERT_WINDOW) require the app to be set as the default launcher or to have special privileges granted via ADB:


adb shell pm grant host.exp.exporter android.permission.SYSTEM_ALERT_WINDOW

In an EAS build, you can add this permission to the android.permissions array in app.json and then rely on the runtime request.

How to Test Permission Dialogs on React Native (Complete Guide): CI/CD Integration and SUSA

Integrating permission‑dialog tests into your continuous delivery pipeline ensures that regressions are caught before they reach users. Below we outline a typical CI setup using GitHub Actions, how to incorporate Fastlane for beta distribution, and where an autonomous QA platform like SUSA can add value.

GitHub Actions Workflow Example


name: Permission Test Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        api-level: [24, 28, 30, 33]   # Android versions to test
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'
      - name: Set up Android SDK
        uses: android-actions/setup-android@v2
      - name: Cache Gradle
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
      - name: Run unit tests
        run: |
          yarn install --frozen-lockfile
          yarn test
      - name: Build debug APK
        run: |
          cd android && ./gradlew assembleDebug
      - name: Install Detox dependencies
        run: |
          npm i -g detox-cli
          detox test --configuration android.emu.debug --loglevel verbose

For iOS, you would add a macOS runner and use xcodebuild to produce a .app for the simulator, then run Detox with the ios.sim.debug configuration.

Fastlane for Beta Distribution

Fastlane can automate the process of uploading a new build to Firebase App Distribution or TestFlight after your permission tests pass. A simplified Fastfile lane might look like:


lane :beta do
  gradle(
    task: 'assemble',
    build_type: 'release'
  )
  firebase_app_distribution(
    app: "1:1234567890:android:abcdef",
    groups: "qa-team",
    release_notes: "Permission dialog regression tests passed",
    firebase_cli_token: ENV['FIREBASE_TOKEN']
  )
end

You can chain this lane after the Detox job in GitHub Actions, ensuring that only builds that have passed permission testing are distributed to testers.

Leveraging SUSA for Autonomous Exploration

SUSA (Susatest) is an autonomous QA platform that explores your app without pre‑written scripts. After you upload an APK or point it at a web URL, SUSA launches the app and begins interacting with UI elements using a variety of user personas (curious, impatient, novice, adversarial, elderly, accessibility‑focused, power user, etc.). Each persona has its own behavior profile—taps, scrolls, typing speed, tolerance for delays, and likelihood to grant or deny permissions.

When SUSA encounters a permission dialog, it treats it like any other UI element and decides, based on the persona, whether to tap Allow, Deny, or (on Android) check the “Never ask again” box before denying. Because the exploration is driven by statistical models rather than hard‑coded assertions, SUSA can surface issues that scripted tests often miss, such as:

SUSA automatically generates regression scripts (Appium for Android, Playwright for Web) from the flows it discovers, which you can then commit to your repository and run in CI. This creates a feedback loop: the more you run SUSA, the smarter its exploration becomes, as it remembers previously visited screens and dead ends.

To use SUSA in your CI, you can add a step that uploads the latest build to the SUSA cloud and triggers an exploration run:


- name: Upload to SUSA and run exploration
  uses: susatest/action@v1
  with:
    api-key: ${{ secrets.SUSA_API_KEY }}
    build-path: ./android/app/build/outputs/apk/debug/app-debug.apk
    personas: curious,impatient,elderly,accessibility
    max-depth: 5
    output-format: junit

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free