How to Test Permission Dialogs on Flutter (Complete Guide)
How to Test Permission Dialogs on Flutter (Complete Guide)
How to Test Permission Dialogs on Flutter (Complete Guide)
Testing permission dialogs in Flutter apps is critical because a mishandled request can lead to crashed screens, denied‑by‑default user flows, privacy complaints, or even App Store rejection. This guide walks you through why permission handling matters, builds a comprehensive test matrix, shows manual and automated techniques, introduces Flutter‑specific tooling, and explains how autonomous, persona‑driven exploration surfaces bugs that scripted tests miss. Every section contains concrete steps, code examples, and tables you can copy into your own project.
Why Permission Dialog Testing Matters in Flutter
Impact on user trust and compliance
When a Flutter app asks for camera, location, or microphone access, the operating system presents a native dialog. If the app does not handle the user’s response correctly—whether they tap Allow, Deny, or Deny‑don’t‑ask‑again—the UI can freeze, navigation can break, or sensitive data can be accessed without proper consent. In regulated markets (GDPR, CCPA, HIPAA) missing a proper rationale or failing to respect a persistent deny can result in legal penalties and negative store reviews. A single uncaught permission bug often shows up only after a release because the dialog depends on the device’s OS version, language settings, and the user’s prior interaction history.
Common failure modes in production
- Unhandled denial – The app proceeds as if permission was granted, leading to a crash when trying to access the protected resource.
- Missing rationale – On Android 6+ and iOS, the system may show a rationale dialog only if the app provides a custom explanation; omitting it causes the system to deny automatically after a few attempts.
- Incorrect state restoration – After a denial, navigating away and returning to the permission screen may not re‑prompt, leaving the user stuck.
- Accessibility gaps – TalkBack or VoiceOver may not announce the dialog’s options, making it unusable for users who rely on screen readers.
- Security‑phishing vectors – A malicious overlay can mimic the system permission dialog; if the app does not verify the source of the callback, it could grant privileges to a fake prompt.
Understanding these failure modes shapes the test matrix that follows.
Test Matrix for Permission Dialogs
The table below enumerates the core scenarios you should cover for each permission type (camera, location, microphone, contacts, storage, etc.). Adjust rows for permissions that are relevant to your app.
| Permission | Test ID | Scenario | Preconditions | Action | Expected Result | Post‑condition |
|---|---|---|---|---|---|---|
| Camera | CAM‑01 | Happy path – first‑time request | No prior permission state | Trigger camera picker | System dialog shows Allow/Deny; tapping Allow grants permission | App proceeds to camera preview |
| Camera | CAM‑02 | Deny – first‑time request | No prior permission state | Trigger camera picker; tap Deny | Permission denied; app receives false from permission_handler | App shows rationale or disables camera feature |
| Camera | CAM‑03 | Deny‑don’t‑ask‑again | Previously denied once | Trigger camera picker; tap Deny & check “Don’t ask again” | System does not show dialog on subsequent triggers; permission_handler returns false permanently | App must provide a settings‑shortcut to re‑enable |
| Location | LOC‑01 | While‑in‑use request | Location not granted | Trigger location fetch | Dialog shows Allow while using app / Allow only this time / Deny | App receives appropriate status |
| Location | LOC‑02 | Background request (Android 10+) | While‑in‑use granted | Request background location | Separate dialog for background access appears | App only gets background permission if user grants |
| Microphone | MIC‑01 | Interrupted by call | Microphone granted | Start audio recording; receive phone call | Recording pauses; system may re‑show dialog after call ends | App resumes or stops gracefully |
| Storage | STO‑01 | Scoped storage (Android 11+) | No storage permission | Attempt to save file to external directory | System shows scoped storage dialog; allowed access to app‑specific folder only | File written to app‑specific directory |
| Contacts | CON‑01 | Permission revoked via settings | Permission granted | Go to system settings → Apps → Your app → Permissions → Contacts → Deny | Next attempt to read contacts returns empty list; no dialog shown | App handles empty data state |
Notes on the matrix
- Preconditions often involve clearing app data or using
adb shell pm reset-permissionsto start from a known state. - Post‑condition checks should verify both the permission plugin’s return value and the UI state (enabled/disabled buttons, toast messages, navigation).
- For iOS, add rows that test the “Don’t Allow” vs “Allow While Using App” distinction and verify that the
Info.plistusage description strings are displayed correctly.
A second table compares the effort and coverage of manual versus automated techniques for each scenario.
| Technique | Setup Time | Execution Speed | Coverage (Happy/Error/Edge) | Maintenance Overhead | Best For |
|---|---|---|---|---|---|
| Manual device testing | Low (just a device) | Slow (human) | High (can observe subtle UX) | Low (no code) | Exploratory, accessibility checks |
| Unit test with mocks | Medium (mock platform channel) | Fast (sub‑second) | Medium (logic only) | Low (mock updates) | Verifying permission‑handler wrappers |
| Integration test (flutter_driver) | High (setup driver) | Medium (device/emulator) | High (UI + logic) | Medium (test flakiness) | CI pipelines, regression |
| Autonomous persona exploration (SUSA) | Very low (CLI install) | Medium (depends on depth) | Very high (covers unexpected paths) | Low (no test code) | Finding regressions, edge‑case bugs |
Manual Testing Approach Step‑by‑Step
Setting up a device or emulator
- Choose a representative OS version – For Android, test API 23 (runtime permissions introduced) and the latest API; for iOS, test iOS 13+ where permission prompts changed.
- Clear app data – Run
adb shell pm clear com.example.yourappor delete the app from the simulator to guarantee a fresh permission state. - Enable accessibility services – Turn on TalkBack (Android) or VoiceOver (iOS) to verify that the dialog is announced correctly.
- Install a logging tool – Use
adb logcat | grep Permissionor Xcode’s console to capture the callback frompermission_handler.
Triggering the dialog
- Direct API call – Invoke the method that requests permission (e.g.,
Permission.camera.request()). - User‑flow trigger – Navigate to the screen that initiates the request (e.g., pressing a “Take Photo” button) to ensure the request happens in the correct UI context.
- Conditional trigger – For permissions that only appear after a prior state (e.g., background location after while‑in‑use granted), first grant the initial permission, then call the upgrade method.
Observing behavior
- Visual check – Confirm the native dialog appears, not a custom overlay. Note the exact wording of the system message and any rationale you supplied via
PermissionHandler.openAppSettings()orInfo.plist. - Interaction – Tap each button (Allow, Deny, Don’t allow again if present) and watch the app’s response.
- State verification – After each interaction, query the permission status (
Permission.camera.status) and assert it matches expectation. - Accessibility check – With TalkBack/VoiceOver enabled, swipe to focus the dialog and listen for announcements of each action. Ensure the focus order is logical and that activating an option yields the expected result.
- Error injection – Simulate a denial via settings (
adb shell pm revoke com.example.yourapp android.permission.CAMERA) while the app is in the foreground, then attempt to use the feature again to see if the app handles the sudden loss gracefully.
Recording results
Create a simple spreadsheet with columns matching the test matrix (Test ID, Scenario, Result, Comments, Evidence). Attach screenshots or short video clips for any failed case. This artifact becomes the baseline for future regression checks.
Automated Testing with Flutter Test and Integration Test
Unit testing permission logic with mocks
The permission_handler plugin communicates with the native side via platform channels. In unit tests you can replace the channel with a mock using the mockito package.
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:permission_handler/permission_handler.dart';
// Mock the MethodChannel used by permission_handler
class MockMethodChannel extends Mock implements MethodChannel {}
void main() {
late MockMethodChannel mockChannel;
setUp(() {
mockChannel = MockMethodChannel();
// Inject the mock into the plugin (requires exposing a setter or using dependency injection)
PermissionHandlerMock.bindMock(mockChannel);
});
test('requestCamera returns true when user allows', () async {
when(mockChannel.invokeMethod<bool>('Permission.request', any))
.thenAnswer((_) async => true);
final status = await Permission.camera.request();
expect(status, isTrue);
});
test('requestCamera returns false when user denies', () async {
when(mockChannel.invokeMethod<bool>('Permission.request', any))
.thenAnswer((_) async => false);
final status = await Permission.camera.request();
expect(status, isFalse);
});
}
*Key points*:
- Mock only the channel invocation; avoid calling real native code.
- Test both branches (true/false) and edge cases like a
PlatformExceptionbeing thrown. - Keep these tests fast; they run in the VM without a device.
Integration test using flutter_driver
Integration tests exercise the full Flutter tree on a device or emulator. The integration_test package provides a driver‑based API.
- Add dependencies in
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
- Create
integration_test/permission_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:your_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Permission flow', () {
testCameraRequest() async {
app.main();
await tester.pumpAndSettle();
// Tap button that triggers camera request
await tester.tap(find.byKey(const Key('cameraButton')));
await tester.pump(); // let the native dialog appear
// On Android we cannot interact with the native dialog from Flutter,
// but we can verify that the callback fires.
// Simulate the platform channel response:
const MethodChannel channel = MethodChannel('flutter.baseflow.com/permissions/permission_handler');
await channel.invokeMethod<bool>('Permission.request', <String, dynamic>{'permission': 'camera'}).then((value) {
// value is what the plugin would return; we assert UI reflects it
expect(value, equals(true)); // assume we mocked Allow
});
// Verify UI updates (e.g., camera preview appears)
await tester.pumpAndSettle();
expect(find.byType(CameraPreview), findsOneWidget);
}
});
}
*Note*: Direct interaction with the system permission dialog is not possible from Flutter tests because it runs outside the Flutter engine. The pattern above shows how to mock the platform channel response at the test level, letting you assert UI changes based on the granted/denied outcome.
Using golden tests for UI
Golden tests ensure that permission‑related UI (e.g., a rationale dialog you build) renders correctly across screen sizes and font scales.
testWidgets('Permission rationale renders correctly', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: PermissionRationaleWidget(permission: Permission.camera),
),
));
await tester.pump(const Duration(seconds: 1));
expect(await tester.goldenMatcher, matchesGoldenFile('permission_rationale_camera.png'));
});
Run flutter test --update-goldens to regenerate baselines when you intentionally change the UI.
Tooling Specific to Flutter
permission_handler package testing
The permission_handler plugin is the de‑facto way to request runtime permissions. Its API returns a PermissionStatus enum (granted, denied, deniedForever, limited, provisional). When writing tests, remember:
- iOS – The
limitedstatus appears when the user grants access to only selected photos (iOS 14+). - Android –
deniedForeveris only set after the user checks “Don’t ask again” and denies; on Android 13+ the concept changes to “permanently denied” and requires an explicit intent to settings.
Always check the plugin’s changelog for breaking changes; the test matrix should be revisited whenever you upgrade.
mockito for mocking platform channels
As shown in the unit‑test snippet, mockito lets you stub the MethodChannel used by permission_handler. For more complex interactions (e.g., handling a stream of permission status changes), you can mock StreamChannel:
when(mockChannel.invokeMethod<bool>('Permission.request', any))
.thenAnswer((_) async => false);
when(mockChannel.getMethodCallHandler())
.thenAnswer((_) => _MockStreamHandler());
integration_test with flutter_test
The integration_test package runs on a real device or emulator, giving you confidence that the native dialog appears. Use the flutter drive command to execute:
flutter drive \
--target=integration_test/permission_test.dart \
--dry-run # (optional) verify script correctness
For CI, consider using Firebase Test Lab or GitHub Actions with Android emulator and iOS simulators.
Using Firebase Test Lab
Firebase Test Lab lets you run your integration tests on a matrix of devices and OS versions without maintaining a local farm.
- Build an APK for Android and an IPA for iOS.
- Upload to Test Lab via the console or
gcloudCLI: - Review the test logs for any permission‑related failures; the platform logs show whether the dialog was displayed and how the app reacted.
gcloud firebase test android run \
--type instrumentation \
--app app-debug.apk \
--test integration_test.apk \
--device model=Pixel3,version=33,locale=en,orientation=portrait
Autonomous, Persona‑Driven Exploration with SUSA
How SUSA discovers permission dialogs
SUSA (SUSATest) explores an app without pre‑written scripts by simulating real‑world user behaviors. It starts from the launcher icon, taps, scrolls, types, and reacts to system dialogs—including permission prompts—based on the active persona’s profile. When a permission dialog appears, SUSA records:
- Whether the dialog was recognized as a system prompt (versus a custom overlay).
- The time taken to dismiss or act on it.
- The subsequent UI state (crash, ANR, navigation to error screen, etc.).
- Any accessibility violations reported by the device’s accessibility service.
Because SUSA does not rely on hardcoded locators, it can reach permission triggers that are hidden behind dynamic UI states (e.g., a permission request that only appears after a user completes a multi‑step onboarding flow).
Persona profiles that trigger edge cases
SUSA ships with several built‑in personas, each with distinct tendencies:
| Persona | Behavior traits relevant to permissions |
|---|---|
| Curious | Tries every button, often taps Allow immediately to see what happens. |
| Impatient | Rapidly taps through dialogs, sometimes double‑tapping Deny. |
| Novice | Reads the rationale carefully, may tap Learn More or Settings before deciding. |
| Adversarial | Actively denies permissions, then attempts to force‑grant via settings or overlay attacks. |
| Elderly | Slower interactions, may miss timed dialogs, relies heavily on screen reader announcements. |
| Accessibility | Uses TalkBack/VoiceOver exclusively; verifies that focus lands on each action and that labels are spoken. |
| Power user | Frequently toggles permissions via settings, tests revocation/re‑grant cycles. |
When SUSA runs with the Adversarial persona, it attempts to overlay a fake permission dialog using SYSTEM_ALERT_WINDOW (if the app mistakenly grants that permission) to see if the app distinguishes the real system prompt from a spoof. The Accessibility persona checks that the dialog’s actions are correctly announced and that the focus order respects logical grouping.
Example of a bug found only by autonomous testing
In a recent Flutter e‑commerce app, SUSA’s Novice persona repeatedly triggered the location permission request while browsing product details. The app’s code only requested location when the user pressed “Find Nearby Stores”. However, a hidden analytics module called LocationService.getCurrentPosition() on every page view, causing a permission prompt to appear unexpectedly. The Novice persona, after reading the rationale, tapped “Allow while using the app” but then immediately pressed the back button, leaving the app in a state where the location callback was never invoked, resulting in a silent failure of the store‑finder feature. Manual test scripts that followed the happy‑path navigation never saw this background request, so the bug escaped detection until SUSA flagged it via a mismatch between expected UI (store list) and actual UI (empty list) after the persona’s exploration path.
Accessibility and Security Considerations
WCAG checks for permission dialogs
Even though the permission dialog is native, your app still has responsibilities:
- Contrast and text size – Ensure any custom rationale you show before calling the system dialog meets WCAG AA contrast (≥4.5:1) and supports dynamic type.
- Labeling – If you display a custom explanation, provide accessible labels (
Semantics.label) so screen readers can read it. - Focus management – After the system dialog is dismissed, return focus to the element that triggered the request (e.g., the button) to avoid disorienting users.
- Error messages – If a permission is denied and a feature cannot be used, convey the issue via an accessible toast or snackbar with an actionable “Open settings” link.
You can automate some of these checks using the flutter_launcher_icons package’s accessibility scanner or by running adb shell am start -a android.intent.action.VIEW -d "https://developer.android.com/guide/topics/ui/accessibility" on a device and using the built‑in accessibility test service.
Preventing phishing‑style fake dialogs
A malicious app could try to draw an overlay that mimics the system permission dialog. Defenses include:
- Verify the source of the callback – The
permission_handlerplugin only returns a result when the *system* dialog is interacted with; a fake overlay cannot trigger the genuine platform channel unless it hijacks the method call (which requires theSYSTEM_ALERT_WINDOWpermission, a privileged flag rarely granted to third‑party apps). - Check the permission status after the dialog – If you receive a
grantedstatus but the UI does not show the expected feature (e.g., camera preview), treat it as suspicious and log an anomaly. - Educate users – In your rationale, mention that the permission request will come from the operating system, not from an in‑app pop‑up.
Ensuring proper rationale strings
Both Android and iOS require a short explanation in the manifest or Info.plist. Test that these strings appear in the system dialog:
- Android – In
AndroidManifest.xml,must be accompanied by. Actually, the rationale is supplied viaActivity.shouldShowRequestPermissionRationale(); you can test this by callingPermission.camera.shouldShowRequestRationale()after a denial and verifying it returnstruewhen appropriate. - iOS – Add keys like
NSCameraUsageDescriptionwith a clear sentence. Run the app on a device and confirm the text appears exactly as written.
Checklist and Takeaways
Quick reference checklist
| ✅ | Item | |
|---|---|---|
| 1 | Verify each permission type used by the app has a corresponding row in the test matrix. | |
| 2 | Test happy path (grant) and both denial paths (temporary and permanent). | |
| 3 | Confirm that custom rationale strings appear in the system dialog when required. | |
| 4 | Check accessibility: screen reader announces each option and focus returns correctly after dismissal. | |
| 5 | Simulate revocation via system settings while the app is foreground; ensure graceful handling. | |
| 6 | Run unit tests with mocked platform channels for all permission‑handler wrappers. | |
| 7 | Execute integration tests on at least one Android API ≥2 and one iOS simulator ≥ 8 | Run autonomous exploration (e.g., SUSA) with the Accessibility and Adversarial personas to surface hidden prompts. |
| 9 | Inspect logs for any PlatformException or unexpected permission status after each interaction. | |
| 10 | Document any deviation from expected behavior and add a regression test. |
Final recommendations
- Treat permission handling as a first‑class feature, not an afterthought. Encapsulate every request in a thin service layer that returns a rich result (status, rationale needed, settings shortcut) so UI components stay declarative.
- Automate the logic with unit tests, but never rely solely on them for UI‑level validation. Use integration tests on real devices to confirm that the native dialog appears and that your app reacts correctly.
- Leverage persona‑driven exploration to catch the rare, context‑specific prompts that static test suites miss—especially those triggered by background services, deep links, or dynamic feature flags.
- Revisit the matrix whenever you add a new permission, upgrade
permission_handler, or target a new OS version. A lightweight spreadsheet or markdown table keeps the matrix living alongside your code. - Monitor production with crash analytics and custom events that fire when a permission request is shown, granted, or denied. Anomalies in the denial rate often point to UX confusion or missing rationale.
By following the matrix, applying both manual and automated techniques, and validating with autonomous, persona‑driven testing, you’ll ship Flutter apps that respect user privacy, stay compliant with regulations, and avoid the embarrassing permission‑related bugs that slip through in release builds. Happy testing!
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