How to Test OTP Verification on Flutter (Complete Guide)

How to Test Otp Verification on Flutter (Complete Guide): Understanding OTP Flow in Flutter Apps

February 09, 2026 · 15 min read · How-To Guides

How to Test Otp Verification on Flutter (Complete Guide): Understanding OTP Flow in Flutter Apps

One‑time password (OTP) verification is a gatekeeper for many Flutter applications, protecting sign‑in, password reset, and transaction flows. When the OTP screen works correctly, users gain confidence that their identity is validated without friction. When it fails, the consequences range from abandoned onboarding to security breaches and regulatory penalties. This guide walks you through why OTP verification matters, what commonly breaks in production, a full test matrix, manual and automated testing techniques, accessibility and security considerations, and how autonomous, persona‑driven exploration can surface bugs that scripted tests miss. Every section includes concrete examples, code snippets, and checklists you can copy into your repository today.

How to Test Otp Verification on Flutter (Complete Guide): Why OTP Verification Breaks in Production

Even a seemingly simple OTP screen hides a web of timing, state, and integration concerns. In production you’ll often see failures that never appeared in local emulators because of the following factors:

Understanding these failure modes informs the test matrix that follows.

How to Test Otp Verification on Flutter (Complete Guide): Comprehensive Test Matrix

Below is a detailed matrix that covers happy paths, error paths, edge cases, accessibility, and security. Each row includes a unique identifier, a concise description, the exact steps to reproduce, the expected outcome, and a priority rating (P0 = blocker, P1 = high, P2 = medium). Feel free to copy this table into a spreadsheet or test‑management tool.

IDScenarioStepsExpected ResultPriority
OTP‑01Happy path – correct OTP entered within validity1. Trigger OTP request (e.g., tap “Send Code”). 2. Wait for SMS simulator to deliver code “123456”. 3. Enter six digits. 4. Tap “Verify”.Navigation proceeds to next screen (e.g., home page). No error toast.P0
OTP‑02OTP expired before submission1. Request OTP. 2. Wait 70 seconds (past expiry). 3. Enter the original code. 4. Tap “Verify”.UI shows “Code has expired, please request a new one”. Verify button remains disabled until new OTP is sent.P0
OTP‑03Incorrect OTP (wrong digits)1. Request OTP. 2. Receive code “654321”. 3. Enter “111111”. 4. Tap “Verify”.Error toast: “Invalid OTP. Please try again”. Input fields stay focused, allowing another attempt.P0
OTP‑04Resend OTP after expiry1. Request OTP. 2. Wait 40 seconds. 3. Tap “Resend Code”. 4. Wait for new SMS (e.g., “654321”). 5. Enter new code. 6. Tap “Verify”.New OTP accepted, flow proceeds. Old code is rejected if entered after resend.P1
OTP‑05Network failure during OTP request1. Disable network (airplane mode). 2. Tap “Send Code”. 3. Wait for timeout.UI shows “Unable to send code. Check connection and retry”. Send button re‑enabled after timeout.P1
OTP‑06Network failure during verification1. Request OTP and receive code. 2. Enable airplane mode before tapping Verify. 3. Tap Verify.UI shows “Verification failed. Please check your network”. OTP fields remain populated for retry.P1
OTP‑07Maximum attempts exceeded1. Request OTP. 2. Enter wrong code five times (assuming limit = 5). 3. On sixth attempt, tap Verify.UI shows “Too many attempts. Please try again later”. Send button disabled for a cool‑down period (e.g., 5 minutes).P0
OTP‑08OTP leakage in logs1. Enable verbose logging. 2. Request OTP and receive code. 3. Submit code.No OTP value appears in console, logcat, or crash reports.P0
OTP‑09Accessibility – label association1. Enable TalkBack. 2. Focus moves to first OTP box.TalkBack announces “Enter digit 1 of 6, secure text entry”. Each subsequent field announces its position.P1
OTP‑10Accessibility – contrast ratio1. Inspect OTP input background vs. text color.Contrast ratio ≥ 4.5:1 for normal text, ≥ 3:1 for large text (WCAG AA).P1
OTP‑11Internationalization – right‑to‑left layout1. Set device locale to Arabic (ar). 2. Open OTP screen.OTP fields flow from right to left, cursor starts at rightmost field.P2
OTP‑12Session persistence after verification1. Complete OTP verification. 2. Background the app, then restore.User remains on post‑verification screen; no forced re‑entry of OTP.P2
OTP‑13OTP request throttling1. Rapidly tap “Send Code” ten times within 2 seconds.Only one request is sent; subsequent taps show “Please wait before requesting another code”.P1
OTP‑14Handling of non‑numeric input1. Tap an OTP field and paste “abc123”.Field rejects non‑numeric characters, retains only digits, or shows validation error.P2
OTP‑15Cut‑copy‑paste behavior1. Copy a six‑digit code from clipboard. 2. Long‑press first OTP field and paste.Code distributes correctly across all six fields (one digit per field).P2
OTP‑16Autofill integration (Android)1. Enable Autofill service with OTP saved. 2. Focus OTP screen.Autofill suggestion appears; tapping it fills all six fields correctly.P2
OTP‑17Handling of leading zeros1. Receive OTP “001234”. 2. Enter exactly as shown.Verification succeeds; leading zeros are not trimmed.P1
OTP‑18OTP length mismatch (server expects 4 digits)1. Server configured for 4‑digit OTP. 2. Receive “1234”. 3. Attempt to enter six digits.UI prevents entry beyond four digits or shows error if six digits submitted.P1
OTP‑19Concurrent OTP requests (multiple triggers)1. Tap “Send Code” twice quickly before first response.Server receives only one request; UI shows a single loading indicator.P2
OTP‑20Error state persistence1. Submit wrong OTP, see error toast. 2. Without clearing fields, tap Verify again.Error toast reappears; fields remain unchanged unless user edits.P2

This matrix gives you a concrete baseline for both manual and automated verification. The next sections show how to execute each item efficiently.

How to Test Otp Verification on Flutter (Complete Guide): Manual Testing Step‑by‑Step

Manual testing remains valuable for exploratory checks, especially when you need to simulate real‑world conditions like fluctuating networks or accessibility tools. Follow this procedure to cover the matrix above on a physical device or emulator.

1. Device setup

2. Test harness

3. Execute the matrix

For each ID:

a. Follow the steps column exactly.

b. Observe the UI and any toast/snackbar messages.

c. Check the console for unexpected errors or logs.

d. Mark the result as PASS/FAIL and note any deviations.

4. Accessibility checks

5. Security checks

6. Network variability

7. Documentation

By following this step‑by‑step routine you can manually validate the entire matrix in under two hours on a single device, making it practical for release‑candidate verification.

How to Test Otp Verification on Flutter (Complete Guide): Automated Testing Approaches

Automated tests give you confidence that regressions are caught early. Flutter offers three layers—unit, widget, and integration—each suited to different aspects of OTP verification.

Unit tests for pure logic

Isolate functions that generate OTP requests, validate codes, or manage timers. Use mockito or fake_async to control time and network.


// otp_service_test.dart
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/services/otp_service.dart';

class MockHttpClient extends Mock implements http.Client {}

void main() {
  late OtpService service;
  late MockHttpClient client;

  setUp(() {
    client = MockHttpClient();
    service = OtpService(client: client);
  });

  test('verifyOtp returns true for correct code', () async {
    when(client.post(any, body: anyNamed('body')))
        .thenAnswer((_) async => http.Response('{"success":true}', 200));

    final result = await service.verifyOtp('123456');
    expect(result, isTrue);
  });

  test('verifyOtp throws on expired code', () async {
    when(client.post(any, body: anyNamed('body')))
        .thenAnswer((_) async => http.Response('{"error":"expired"}', 400));

    expect(() => service.verifyOtp('123456'), throwsA(isInstanceOf<OtpExpiredException>()));
  });
}

Run with flutter test test/otp_service_test.dart.

Widget tests for UI behavior

Test the OTP input widget itself: focus movement, character limiting, and button state.


// otp_input_widget_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/widgets/otp_input.dart';

void main() {
  testWidgets('OTP input moves focus on each digit', (WidgetTester tester) async {
    await tester.pumpWidget(const MaterialApp(
      home: Scaffold(body: OtpInput(length: 6)),
    ));

    final firstFinder = find.byKey(const Key('otp_field_0'));
    final secondFinder = find.byKey(const Key('otp_field_1'));

    // Tap first field and enter a digit
    await tester.tap(firstFinder);
    await tester.enterText(firstFinder, '1');
    await tester.pump();

    // Focus should have moved to second field
    expect(secondFinder, isFocused);
    expect(find.text('1'), findsOneWidget);
  });

  testWidgets('Verify button disabled when OTP incomplete', (WidgetTester tester) async {
    await tester.pumpWidget(const MaterialApp(
      home: Scaffold(body: OtpInput(length: 6)),
    ));

    await tester.enterText(find.byKey(const Key('otp_field_0')), '123');
    await tester.pump();

    final verifyButton = find.byKey(const Key('verify_button'));
    expect(verifyButton, findsOneWidget);
    expect(verifyButton.evaluate().first.widget as ElevatedButton,
        isNot(enabled));
  });
}

Execute via flutter test test/widget/otp_input_widget_test.dart.

Integration tests for end‑to‑end flows

Use the integration_test package to drive a real device or emulator, simulating SMS delivery via a mock backend.


// integration_test/otp_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart' as app;
import 'package:http/http.dart' as http;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('OTP verification flow', () {
    testWidgets('happy path completes', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Trigger OTP request
      await tester.tap(find.text('Send Code'));
      await tester.pump(const Duration(seconds: 2));

      // Simulate SMS arrival – we inject the code via a mock channel
      const String testCode = '654321';
      // Assume we have a MethodChannel named 'sms_simulator' that the app listens to
      const MethodChannel channel = MethodChannel('sms_simulator');
      await channel.invokeMethod('deliverOtp', {'code': testCode});

      // Fill OTP fields
      for (int i = 0; i < testCode.length; i++) {
        final fieldFinder = find.byKey(Key('otp_field_$i'));
        await tester.tap(fieldFinder);
        await tester.enterText(fieldFinder, testCode[i]);
        await tester.pump();
      }

      // Tap Verify
      await tester.tap(find.text('Verify'));
      await tester.pumpAndSettle();

      // Expect navigation to home screen
      expect(find.text('Welcome'), findsOneWidget);
    });
  });
}

Run with flutter drive --target=test_driver/app.dart or the shorthand flutter test integration_test/otp_flow_test.dart.

Mocking the backend

Instead of relying on a real SMS gateway, spin up a lightweight mock server (e.g., mocktail for Dart or WireMock in a Docker container). The app can point to http://10.0.2.2:8080/otp on the emulator, which returns predefined JSON responses.


# Start WireMock container
docker run -d -p 8080:8080 wiremock/wiremock
# Add a mapping for OTP request
curl -X POST http://localhost:8080/__admin/mappings \
  -H "Content-Type: application/json" \
  -d '{
        "request": { "method": "POST", "url": "/otp" },
        "response": { "status": 200, "jsonBody": { "success": true } }
      }'

In your Flutter code, configure the base URL via an environment variable or a flavor (flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8080).

Flutter Driver for legacy projects

If you still use flutter_driver, the same logic applies: locate widgets by ValueKey, send text, and assert navigation. The integration_test approach is now preferred because it runs faster and integrates with the test runner.

By combining unit, widget, and integration tests you achieve fast feedback loops (unit/widget) while still validating real device behavior (integration). Automate the matrix: map each ID to a test case, tag them (e.g., @p0, @accessibility), and run subsets in your CI pipeline.

How to Test Otp Verification on Flutter (Complete Guide): Accessibility and Internationalization Checks

Accessibility is not an afterthought; it directly impacts conversion rates and legal compliance. The OTP screen is a frequent choke point for users with visual, motor, or cognitive impairments.

Labeling and focus order

Every OTP input must have an associated Semantics label that announces its position. Avoid relying solely on placeholder text, as TalkBack reads placeholders only when the field is empty.


Semantics(
  label: 'Enter digit ${index + 1} of $length',
  child: TextField(
    key: Key('otp_field_$index'),
    maxLength: 1,
    keyboardType: TextInputType.number,
    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      counterText: '',
    ),
  ),
);

Run the following accessibility test using the integration_test package and the accessibility_tools plugin:


testWidgets('OTP fields have correct semantics labels', (WidgetTester tester) async {
  await tester.pumpWidget(const MaterialApp(
    home: Scaffold(body: OtpInput(length: 6)),
  ));

  for (int i = 0; i < 6; i++) {
    final finder = find.byKey(Key('otp_field_$i'));
    expect(finder, findsOneWidget);
    final semantics = tester.firstWidget<Semantics>(finder);
    expect(semantics.label, contains('Enter digit ${i + 1} of 6'));
  }
});

Contrast and touch targets

Use the contrast_checker package in a widget test to assert that the foreground/background contrast meets WCAG AA (≥ 4.5:1). Touch targets should be at least 48 dp; you can verify this by checking the size property of the rendered RenderBox.


testWidgets('OTP field meets contrast ratio', (WidgetTester tester) async {
  await tester.pumpWidget(const MaterialApp(
    home: Scaffold(body: OtpInput(length: 6)),
  ));

  final field = find.byKey(const Key('otp_field_0'));
  final render = tester.renderObject<RenderBox>(field);
  final bgColor = render.paintBounds; // placeholder – actual extraction depends on your theme
  // Use contrast_checker to compute ratio; assert >= 4.5
});

Right‑to‑left (RTL) support

Set the locale to an RTL language and verify that the OTP fields reverse order. The Directionality widget propagates the layout direction.


testWidgets('OTP layout mirrors in RTL', (WidgetTester tester) async {
  await tester.pumpWidget(
    Directionality(
      textDirection: TextDirection.rtl,
      child: const MaterialApp(
        home: Scaffold(body: OtpInput(length: 6)),
      ),
    ),
  );

  // The first visual field should be the right‑most logical field
  final rightMost = find.byKey(const Key('otp_field_5'));
  expect(rightMost, findsOneWidget);
  expect(tester.getTopLeft(rightMost).dx, greaterThan(tester.getTopLeft(find.byKey(const Key('otp_field_0'))).dx));
});

Localization of messages

Ensure that error strings, timer labels, and button text are pulled from your arb files. Use the flutter_localizations library and write a test that switches locales and checks the displayed text.


testWidgets('OTP error message localizes to Spanish', (WidgetTester tester) async {
  await tester.pumpWidget(
    Locale(
      locale: Locale('es', 'ES'),
      child: const MaterialApp(
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
        home: Scaffold(body: OtpInput(length: 6)),
      ),
    ),
  );

  // Trigger an error (e.g., wrong OTP) and verify the Spanish text appears
  await tester.tap(find.text('Verify'));
  await tester.pump();
  expect(find.text('Código incorrecto. Inténtalo de nuevo'), findsOneWidget);
});

By automating these checks you guard against regressions that would otherwise slip through manual testing, especially when you add new themes or support additional languages.

How to Test Otp Verification on Flutter (Complete Guide): Security and Privacy Considerations

A compromised OTP flow can lead to account takeover, regulatory fines, and loss of user trust. Treat the OTP as a secret credential, even though it is short‑lived.

Rate limiting and brute‑force protection

Implement a per‑identifier (phone/email/IP) counter that blocks further OTP requests after a configurable threshold (e.g., 5 attempts). Reset the counter after a cool‑down period or after successful verification.


class OtpRateLimiter {
  final Map<String, int> _attempts = {};
  final int _maxAttempts;
  final Duration _lockout;

  OtpRateLimiter({this._maxAttempts = 5, this._lockout = const Duration(minutes: 5)});

  bool allowRequest(String key) {
    final now = DateTime.now();
    final count = _attempts[key] ?? 0;
    if (count >= _maxAttempts) {
      final firstAttempt = _attemptTimes[key] ?? now;
      if (now.difference(firstAttempt) < _lockout) return false;
      // reset after lockout expires
      _attempts[key] = 0;
      _attemptTimes.remove(key);
    }
    _attempts[key] = (count + 1);
    _attemptTimes[key] = now;
    return true;
  }
}

Unit‑test this logic to confirm that after five failed requests the sixth is blocked until the lockout elapses.

Avoid logging the OTP

Never pass the OTP to print(), debugPrint(), or logging frameworks. If you use a logging package, configure it to filter out any field named otp or code.


// Example with logger package
logger.filter = Filter.allow(
  (record) => !record.message.contains(RegExp(r'otp|code', caseSensitive: false)),
);

Secure transmission

Enforce HTTPS for all OTP‑related endpoints. In Flutter, you can use dio with an interceptor that throws on non‑secure schemes.


dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    if (!options.uri.toString().startsWith('https://')) {
      return handler.reject(DioError(
        requestOptions: options,
        error: 'Insecure OTP endpoint',
        type: DioErrorType.badResponse,
      ));
    }
    return handler.next(options);
  },
));

Storage of OTP‑derived tokens

After verification, the backend typically returns a session token or auth credential. Store this in Flutter’s flutter_secure_storage or the platform’s keystore, never in SharedPreferences or plain files.


final storage = FlutterSecureStorage();
await storage.write(key: 'auth_token', value: response.token);

Testing security controls

Add a dedicated test suite that attempts to bypass rate limits, sends OTP over HTTP, and inspects logs for leakage. Use the integration_test package to simulate a malicious user:


testWidgets('Rate limit blocks after 5 failed attempts', (WidgetTester tester) async {
  // Mock backend to always return invalid OTP
  // ... setup mock server ...

  for (int i = 0; i < 5; i++) {
    await tester.tap(find.text('Send Code'));
    await tester.pump(const Duration(seconds: 2));
    await tester.enterText(find.byKey(const Key('otp_field_0')), '000000');
    await tester.tap(find.text('Verify'));
    await tester.pump();
    expect(find.text('Invalid OTP'), findsOneWidget);
  }

  // Sixth attempt should show lockout message
  await tester.tap(find.text('Send Code'));
  await tester.pump(const Duration(seconds: 2));
  expect(find.text('Too many attempts. Try again later'), findsOneWidget);
});

By embedding these security checks into your automated suite you ensure that regressions in throttling, logging, or transport security are caught before they reach production.

How to Test Otp Verification on Flutter (Complete Guide): Autonomous, Persona‑Driven Exploration with SUSA

Scripted tests excel at verifying known scenarios, but they cannot anticipate the unpredictable ways real users interact with an app. SUSA (Susatest) explores your Flutter application autonomously, employing a range of user personas—curious, impatient, novice, adversarial, elderly, accessibility‑focused, power user, and more—each with distinct behavior patterns, timing, and error‑prone tendencies. When pointed at an OTP screen, SUSA can surface bugs that a deterministic test suite would never think to try.

How SUSA interacts with OTP flows

Because SUSA drives the UI at the widget layer, it observes the exact same state changes that a real device would see, including asynchronous callbacks, focus shifts, and animation frames.

Example bugs found only via autonomous runs

During a recent exploration of a Flutter‑based banking app, SUSA uncovered the following issues that escaped unit and widget tests:

  1. Duplicate OTP request storm – The impatient persona tapped “Send Code” three times within 300 ms. The backend accepted all three requests, sending three separate SMS codes. The UI, however, only displayed the most recent code, leaving the user confused when the earlier codes failed verification. Fix: debounce the send‑code button with a 2‑second cooldown.
  2. Clipboard paste misalignment – The curious persona long‑pressed an OTP field, selected “Paste”, and inserted a six‑digit code that had been copied from a notes app. The app incorrectly placed the first digit in the second field and shifted the rest, causing verification to fail. Fix: intercept the paste event and distribute characters evenly across all fields.
  3. TalkBack focus loss after error – The accessibility persona triggered an invalid OTP, received a toast, and then attempted to continue typing. TalkBack lost focus, announcing the next element as the “Verify” button instead of the empty OTP field. Fix: after showing an error toast, explicitly request focus on the first OTP field using FocusScope.of(context).requestFocus(_firstFocusNode).
  4. Rate‑limit bypass via rapid resend – The adversarial persona repeatedly tapped the resend button while the OTP was still valid, causing the backend to reset the expiry timer each time and effectively granting an unlimited validation window. Fix: server‑side enforce a minimum interval between resend requests, and client‑side disable the resend button for a configurable period.

These defects required non‑deterministic timing, specific gesture sequences, or accessibility state changes—conditions that are difficult to anticipate in a scripted test but emerge naturally when SUSA’s personas explore the UI.

Running SUSA on your Flutter app

Install the agent globally, point it at your APK or a web URL, and let it explore.


# Install

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