How to Test Push Notifications on Flutter (Complete Guide)

How to Test Push Notifications on Flutter (Complete Guide)

February 05, 2026 · 19 min read · How-To Guides

How to Test Push Notifications on Flutter (Complete Guide)

Push notifications are a critical engagement channel for mobile apps, yet they are also one of the most fragile parts of a Flutter application. A mis‑configured payload, a missing permission check, or an unhandled tap can silently drop messages, crash the app, or expose user data. Because notifications travel outside the Dart VM—through platform‑specific services like Firebase Cloud Messaging (FCM) on Android and Apple Push Notification service (APNs) on iOS—traditional unit tests that only exercise Dart logic miss many failure modes. This guide walks you through a complete, battle‑tested strategy for verifying that push notifications work reliably across devices, personas, and failure conditions. You’ll find a detailed test matrix, step‑by‑step manual procedures, automated approaches that fit into CI pipelines, real‑world code snippets, and a short checklist you can bookmark. Throughout, we show how autonomous, persona‑driven exploration (the kind SUSATest performs) surfaces bugs that scripted tests never think to look for.

How to Test Push Notifications on Flutter (Complete Guide) – Why It Matters

Push notifications sit at the intersection of three layers: the Flutter UI, the platform‑specific messaging plugin, and the backend push service. When any layer misbehaves, the user experience suffers. Common production issues include:

Because these problems often surface only after a specific combination of device OS version, plugin version, and network condition, a disciplined testing approach is essential. The following sections give you a repeatable process that covers the full spectrum of failure modes.

How to Test Push Notifications on Flutter (Complete Guide) – Test Matrix

A well‑structured test matrix ensures you exercise every relevant path. Below is a comprehensive matrix that you can copy into a test‑management tool or a simple spreadsheet. Each row represents a test scenario; columns indicate the dimension you are validating.

IDCategorySub‑categoryDescriptionExpected ResultAutomation Feasibility
H1Happy PathForegroundApp is in foreground, notification arrives, UI shows banner/in‑app alertNotification displayed, onMessage handler called, state updated correctlyHigh (flutter_test + mock FCM)
H2Happy PathBackgroundApp in background, notification arrives, user taps itApp opens to correct route, payload parsed, deep‑link handling worksMedium (integration test on device)
H3Happy PathTerminatedApp not running, notification arrives, user taps itApp launches from cold start, initial route reflects notification dataMedium (requires device automation)
E1Error PathMissing PermissionAndroid: POST_NOTIFICATIONS denied; iOS: user denied alertsNo notification shown, plugin logs permission error, graceful fallback UI shownMedium (permission mock)
E2Error PathInvalid PayloadBackend sends malformed JSON (missing data field)Plugin does not crash, onMessage receives null or empty map, app shows generic error toastHigh (unit test with bad JSON)
E3Error PathExpired TokenFCM token revoked, backend sends to old tokenBackend receives NotRegistered error, app should refresh token on next launchLow (needs backend simulation)
E4Error PathNetwork FailureDevice offline when push sentNo notification queued; when connectivity returns, pending notification delivered (if APNs/FCM supports)Medium (network throttling)
E5Error PathHeavy CallbackNotification handler performs 2‑second synchronous workNo frame drops, UI remains responsive (<16ms per frame)Low (requires performance test)
A1AccessibilityTalkBack/VoiceOverNotification triggers UI change (e.g., new badge)Change announced correctly, focus moves to new element if appropriateMedium (semantics test)
A2AccessibilityFont ScalingUser has set largest font sizeNotification UI respects scaling, no overflow or clippingLow (visual test)
S1Security/PIIPayload LoggingApp logs entire RemoteMessage to consoleNo PII appears in logs; only non‑sensitive identifiers loggedHigh (unit test with spy logger)
S2Security/PIIAnalytics TrackingApp sends notification click event to analyticsEvent payload stripped of personal data before transmissionMedium (mock analytics)
S3Security/PIIDeep Link ValidationNotification contains a URL that could navigate to external siteApp validates URL scheme, blocks navigation to non‑whitelisted domainsMedium (unit test with URL validator)

*Notes*

How to Test Push Notifications on Flutter (Complete Guide) – Manual Step‑by‑Step Approach

Even with strong automation, a manual exploratory pass catches edge cases that scripts overlook—especially those tied to device‑specific OEM behaviors, battery optimizations, or user‑initiated settings changes. Follow this procedure on both Android and iOS devices (or emulators/simulators) before each release.

1. Prepare the Test Environment

  1. Install two builds: a debug build with verbose logging enabled and a release‑like profile build to catch performance‑related issues.
  2. Enable platform logging:
  1. Clear notification history: On Android, go to Settings → Apps → YourApp → Notifications → Notification history and clear; on iOS, swipe away all notifications in the Notification Center.
  2. Set device to default battery optimization: Disable any aggressive battery saver that might defer background delivery.

2. Register and Retrieve the Push Token

Run the app and observe the console for the token output from FirebaseMessaging.instance.getToken(). Record it; you’ll need it to send test messages from your backend or from the Firebase Console.


void _initMessaging() async {
  final token = await FirebaseMessaging.instance.getToken();
  debugPrint('FCM Token: $token');
  FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
    debugPrint('Token refreshed: $newToken');
    // send newToken to your backend
  });
}

3. Trigger Test Notifications

Use one of the following methods to send a notification:

Send three variations:

  1. Minimal payload – only notification.title and notification.body.
  2. Data‑only payload – custom JSON under data: with fields your app expects (e.g., {"type":"promo","id":"123"}).
  3. Mixed payload – both notification and data sections.

4. Observe Foreground Behavior

With the app in the foreground:

5. Observe Background and Terminated Behavior

6. Test Permission Scenarios

7. Stress Test the Callback

Introduce a deliberate delay in your onMessage handler (e.g., await Future.delayed(const Duration(seconds, 2));) and watch for frame drops using Flutter’s performance overlay (flutter run --profile then press p). The UI should remain at 60 fps; if you see jank, move heavy work to an isolate or use WidgetsBinding.addPostFrameCallback.

8. Validate Accessibility

9. Check for Privacy Leaks

10. Document Findings

Create a short test log for each scenario, noting:

Repeat the matrix on at least once on a second physical device (different OEM) to catch manufacturer‑specific quirks (e.g., Xiaomi’s MIUI aggressive background restrictions).

How to Test Push Notifications on Flutter (Complete Guide) – Automated Approaches

Automation gives you repeatable confidence and lets you push verification into CI pipelines. Flutter provides several layers you can exploit: unit tests for pure Dart logic, widget tests for UI reactions, and integration tests for end‑to‑end flows on real devices or emulators.

Unit Testing the Notification Handler

Most of your app’s reaction to a notification lives in a Dart class that processes RemoteMessage. Because this class depends only on the firebase_messaging plugin’s public interface, you can mock it using the mockito package.


// notification_handler.dart
class NotificationHandler {
  final FirebaseMessaging _messaging;

  NotificationHandler(this._messaging);

  Future<void> initialize() async {
    await _messaging.requestPermission();
    _messaging.onMessage.listen(_onMessage);
    _messaging.onBackgroundMessage = _onBackgroundMessage;
  }

  void _onMessage(RemoteMessage message) {
    // Example: show a snackbar with the title
    final title = message.notification?.title ?? '';
    final body = message.notification?.body ?? '';
    // In real app, use a ScaffoldMessenger or state management
    debugPrint('Received: $title - $body');
    // TODO: update UI via Streams/Bloc/etc.
  }

  Future<void> _onBackgroundMessage(RemoteMessage message) async {
    // Heavy work should be isolated; here we just log
    debugPrint('Background message: ${message.messageId}');
  }
}

Corresponding test:


// notification_handler_test.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'notification_handler.dart';

@GenerateMocks([FirebaseMessaging])
void main() {
  late MockFirebaseMessaging mockMessaging;
  late NotificationHandler handler;

  setUp(() {
    mockMessaging = MockFirebaseMessaging();
    handler = NotificationHandler(mockMessaging);
  });

  test('onMessage prints title and body', () async {
    // Arrange
    final message = RemoteMessage(
      data: {},
      notification: RemoteNotification(
        title: 'Test Title',
        body: 'Test Body',
      ),
    );

    // Act
    handler._onMessage(message);

    // Assert – verify that debugPrint was called with expected string
    // Since debugPrint goes to stdout, we can capture it via expectLater
    // For brevity, we assert that no exception is thrown.
    expect(() => handler._onMessage(message), returnsNormally);
  });
}

Run with flutter test. This validates that your handler does not throw when faced with null fields.

Widget Testing UI Reactions

If your app shows a Snackbar or updates a badge upon receiving a message, you can simulate that by invoking the handler directly in a widget test.


// notification_badge_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/main.dart'; // contains your root widget
import 'notification_handler.dart';

void main() {
  testWidgets('Badge increments on message', (WidgetTester tester) async {
    // Build app with a provider that holds the handler
    await tester.pumpWidget(
      Provider<NotificationHandler>(
        create: (_) => NotificationHandler(FirebaseMessaging.instance),
        child: const MyApp(),
      ),
    );

    // Simulate a message
    final handler = tester.state<NotificationHandlerProvider>(find.byType(Provider));
    final message = RemoteMessage(
      data: {},
      notification: RemoteNotification(title: 'Hi', body: 'There'),
    );
    handler.notificationHandler._onMessage(message);

    // Pump to allow UI reaction
    await tester.pump();

    // Expect badge to show '1'
    expect(find.text('1'), findsOneWidget);
  });
}

Integration Testing End‑to‑End Flows

Integration tests run on a real device or emulator and can actually receive push notifications if you configure the test runner to use a special Firebase project (or a mock FCM server). The integration_test package works well with flutter_driver.

#### Setting Up a Mock FCM Server

For CI, you can run a lightweight mock server that mimics the FCM HTTP endpoint. One popular choice is firebase-mock (Node.js) or the open‑source fcmmock Docker image. Point your app’s google-services.json / GoogleService-Info.plist to a dummy project whose server key is routed to the mock.

#### Sample Integration Test


// integration_test/push_notification_test.dart
import 'package:integration_test/integration_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/main.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Push Notification Flow', () {
    testWidgets('app opens correct screen when tapped', (WidgetTester tester) async {
      // Launch app in background state
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();

      // Simulate receiving a notification while app is backgrounded
      // This uses the platform channel to inject a RemoteMessage
      const String messageJson = '''
      {
        "messageId": "msg_123",
        "notification": {
          "title": "Offer",
          "body": "20% off"
        },
        "data": {
          "type": "promo",
          "id": "42"
        }
      }
      ''';

      // On Android we use the FirebaseMessaging plugin's test API
      // On iOS we can send a silent push via simulator utilities
      if (Platform.isAndroid) {
        await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
          'plugins.flutter.io/firebase_messaging',
          const StringCodec().encodeMessage(messageJson),
          (_) => null,
        );
      } else if (Platform.isIOS) {
        // Use simctl to push a notification; omitted for brevity
      }

      // Pump to allow plugin to deliver the message
      await tester.pump(const Duration(seconds, 2));

      // Tap the notification (simulated by tapping the notification badge)
      await tester.tap(find.byKey(const Key('notificationBadge')));
      await tester.pumpAndSettle();

      // Verify navigation to promo screen
      expect(find.text('Promo Details'), findsOneWidget);
      expect(find.textContaining('ID: 42'), findsOneWidget);
    });
  });
}

Run with:


flutter drive --target=integration_test/push_notification_test.dart -d <device-id>

Performance and Battery Impact Tests

Use Flutter’s built‑in performance overlay to measure UI jank during callback execution. For battery impact, rely on Android’s adb shell dumpsys batterystats or Xcode’s Energy Log. Capture a baseline before sending a burst of notifications (e.g., 10 messages in 10 seconds) and compare the delta.

Continuous Integration Integration

Add the following steps to your CI (GitHub Actions, GitLab CI, Bitrise, etc.):

  1. flutter test – unit & widget tests.
  2. flutter build apk --split-per-abi – ensure release build compiles.
  3. flutter drive --target=integration_test/push_notification_test.dart – run integration on a Firebase Test Lab device matrix (or local emulator pool).
  4. Upload logs and screenshots as artifacts for later review.

How to Test Push Notifications on Flutter (Complete Guide) – Tooling and Libraries

Having the right tooling reduces friction and surfaces issues early. Below is a comparison of the most useful libraries and utilities for Flutter push‑notification testing.

Tool / LibraryPrimary UsePlatform SupportSetup ComplexityNotes
firebase_messagingOfficial FCM/APNs pluginAndroid, iOS, WebLowIncludes onMessage, onBackgroundMessage, getToken.
flutter_local_notificationsShow in‑app notifications, schedule local alertsAndroid, iOSLowUseful for testing UI without needing a remote push.
mockito / mocktailMock FirebaseMessaging in unit testsDart (VM/Flutter)LowEnables deterministic handler testing.
integration_testEnd‑to‑end tests on device/emulatorAndroid, iOS, WebMediumRequires configuring a test Firebase project or mock server.
firebase_test_lab (via gcloud)Run instrumentation tests on a matrix of real devicesAndroidMediumGood for catching OEM‑specific issues.
SUSATest (CLI: susatest-agent)Autonomous exploratory testing with persona profilesAndroid (APK), Web (URL)Low – install via pipGenerates Appium/Playwright regression scripts from discovered flows; can detect missing notification handling, dead buttons after tap, etc.
Android Studio Profiler / Xcode InstrumentsMonitor CPU, memory, battery, and frame drops during notification handlingAndroid, iOSLowEssential for performance validation.
Charles Proxy / mitmproxyInspect network traffic, including FCM/APNs payloadsCross‑platformMediumUseful to verify that no PII leaves the device.
flutter_launcher_icons / flutter_native_splashNot directly related but ensures that the launcher icon appears correctly in the notification shade (Android)Android, iOSLowVisual consistency check.

How to Choose

How to Test Push Notifications on Flutter (Complete Guide) – Autonomous, Persona‑Driven Exploration with SUSATest

Even the most thorough test matrix can miss edge cases that arise only when real users interact with the app in unpredictable ways. SUSATest addresses this by launching an autonomous agent that explores the app without pre‑written scripts, guided by configurable personas that model distinct behaviors.

How It Works

  1. Input – You provide either an APK (Android) or a web URL.
  2. Exploration Engine – The agent uses a combination of computer vision (UI element detection) and accessibility heuristics to discover tappable, scrollable, and input‑capable elements.
  3. Persona Profiles – Each persona has a tuned policy:
  1. Observation – While exploring, the agent monitors logs, crash reports, ANR traces, and UI changes. It also records any notification that arrives (via platform hooks) and verifies that the app reacts as expected.
  2. Reporting – After a run, you receive a JSON report with PASS/FAIL verdicts for each discovered flow, plus screenshots, video, and logs. The agent also generates regression scripts (Appium for Android, Playwright for web) that you can add to your CI.

What It Finds That Scripts Miss

Example: Discovering a Dead Button After Notification Tap

During a SUSATest run with the *elderly* persona on a shopping app, the agent observed the following sequence:

  1. App in background, notification arrives (“Your order shipped”).
  2. User taps the notification (simulated via a long press to mimic slower motor skills).
  3. App opens to the order‑details screen, but the “Track Shipment” button is unresponsive.

Investigation revealed that the navigation handler used Navigator.pushNamed with a route name that was only registered after a certain authentication check. The notification deep link bypassed the auth flow, leaving the button’s onPressed callback bound to a null controller. The bug would not appear in a scripted test that always launched the app via the login flow first.

SUSATest automatically generated an Appium test that reproduces the exact tap sequence, which you can now add to your regression suite.

Integrating SUSATest into Your Workflow


# Install the CLI (once)
pip install susatest-agent

# Run a persona‑driven exploration against your Android build
susatest explore \
  --apk ./build/app/outputs/flutter-apk/app-release.apk \
  --personas curious impatient accessibility \
  --output ./susatest-report.json \
  --generate-scripts   # creates Appium test folder

The generated Appium tests live under susatest_generated/ and can be invoked in your CI pipeline with a standard appium command. Because SUSATest learns from prior runs, each subsequent execution explores new paths while skipping previously verified dead ends, making the process progressively more efficient.

How to Test Push Notifications on Flutter (Complete Guide) – Checklist

Use this concise list as a gate before merging a release candidate or before handing off a build to QA. Tick each item; if any item is red, investigate before proceeding.

ItemHow to Verify
1Token registration succeeds on first launchCheck console for token; ensure no FirebaseException.
2Foreground notification shows UI as designedSend a test message; verify snackbar/dialog/badge appears.
3Background tap opens correct routeSend message, background app, tap notification, assert route.
4Terminated app cold‑starts correctly from notificationSwipe away app, send notification, tap, verify initial state.
5Permission denial handled gracefullyDisable notifications, send message, confirm no crash and optional in‑app prompt.
6Invalid or missing payload does not crashSend malformed JSON, ensure handler logs error and continues.
7Heavy work in callback does not cause jankProfile with performance overlay while inserting delay(2s) in handler.
8Accessibility announcements presentEnable TalkBack/VoiceOver, send notification, listen for announcement.
9No PII leaked in logs or analyticsSearch console/network for raw email, phone, IDs.
10Deep link from notification validatedSend notification with external URL, confirm app blocks or sanitizes.
11Battery impact acceptableRun adb shell dumpsys batterystats before/after a burst of 10 notifications.
12Regression scripts generatedRun SUSATest (or your integration test suite) and confirm no new FAILs.
13Release build passes all automated testsExecute flutter test and flutter drive on CI; all green.
14Manual exploratory pass on at least two device OEMsPerform the matrix steps on a Samsung and a Xiaomi device (or equivalents).

If you can check every box, you have high confidence that the push‑notification subsystem will behave correctly for the majority of real‑world users.

How to Test Push Notifications on Flutter (Complete Guide) – Real‑World Examples and Lessons Learned

Example 1: Missing Permission Handling on Android 13

A finance app updated to target Android 33 (API level 33) introduced the runtime POST_NOTIFICATIONS permission. The developers added the permission request but forgot to handle the case where the user permanently denied it (“Don’t ask again”). When a notification arrived, the firebase_messaging plugin threw a PlatformException that was uncaught, causing the app to crash silently (the crash was only visible in Firebase Crashlytics as a Fatal Exception: io.flutter.plugins.firebasemessaging.FirebaseMessagingException).

Lesson: Always provide a fallback UI that explains why notifications are needed and offers a shortcut to Settings. Use FirebaseMessaging.instance.requestPermission() and check the returned AuthorizationStatus. If status is denied and isPermanentlyDenied is true, launch the settings page via openAppSettings().

Example 2: Payload Parsing Crash Due to Unexpected Null

An e‑commerce app expected every push to contain a data field with a JSON‑encoded orderId. The backend occasionally sent a notification with only the notification title/body and no data field. The Dart code performed jsonDecode(message.data['order'] as String) without a null check, resulting in a FormatException that was caught by a generic try/catch but then re‑threw as a state error, causing the UI to show an blank screen.

Lesson: Treat all incoming fields as optional. Use message.data.containsKey('order') before decoding, and provide a default or error UI when required data is missing. Write unit tests that feed a RemoteMessage with an empty data map to verify graceful handling.

Example 3: Notification Tap Leads to Wrong Locale

A travel app localized its strings based on the device locale at startup. A notification deep link contained a route parameter ?lang=es to force Spanish. However, the navigation guard that reads the locale was executed *before* the deep‑link handler could override it, so the UI remained in English despite the user’s explicit request.

Lesson: When supporting locale overrides via query parameters, read and apply the override *early* in the app lifecycle—ideally in main() before any widgets are built. Write a small integration test that sends a notification with a lang parameter and asserts that the localized strings appear in the correct language.

Example 4: Battery Drain from Wake‑Lock Misuse

A productivity app used a WakeLock to keep the CPU awake while processing a long‑running notification task (e.g., uploading a log file). The developer forgot to release the lock in a finally block, causing the device to stay awake for up to 30 minutes after the notification was handled, drastically reducing battery life. The issue only appeared after a user received several notifications in a short span.

Lesson: Always pair WakeLock.acquire() with WakeLock.release() in a try/finally. Use the Android Battery Historian or Xcode Energy Log to verify that wake‑lock duration matches expected work time.

These cases illustrate that

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