How to Test OTP Verification on Flutter (Complete Guide)
How to Test Otp Verification on Flutter (Complete Guide): Understanding OTP Flow in Flutter Apps
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:
- Network latency and timeout – Mobile networks fluctuate. A request that completes in 200 ms on Wi‑Fi may take 2 seconds on 3G, causing UI spinners to disappear before the server responds.
- Code expiration and resend logic – OTPs typically expire after 30‑60 seconds. If the UI does not disable the “Verify” button after expiration or fails to show a resend timer, users can submit stale codes.
- UI state mismatches – Flutter’s reactive framework can leave the OTP text fields in an inconsistent state when the underlying model updates asynchronously (e.g., after a failed attempt).
- Accessibility oversights – Missing labels, poor contrast, or incorrect focus order prevent TalkBack or VoiceOver users from completing the flow.
- Security gaps – Logging the OTP, insufficient rate limiting, or transmitting the code over plain HTTP opens the door to interception or brute‑force attacks.
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.
| ID | Scenario | Steps | Expected Result | Priority |
|---|---|---|---|---|
| OTP‑01 | Happy path – correct OTP entered within validity | 1. 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‑02 | OTP expired before submission | 1. 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‑03 | Incorrect 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‑04 | Resend OTP after expiry | 1. 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‑05 | Network failure during OTP request | 1. 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‑06 | Network failure during verification | 1. 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‑07 | Maximum attempts exceeded | 1. 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‑08 | OTP leakage in logs | 1. Enable verbose logging. 2. Request OTP and receive code. 3. Submit code. | No OTP value appears in console, logcat, or crash reports. | P0 |
| OTP‑09 | Accessibility – label association | 1. 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‑10 | Accessibility – contrast ratio | 1. Inspect OTP input background vs. text color. | Contrast ratio ≥ 4.5:1 for normal text, ≥ 3:1 for large text (WCAG AA). | P1 |
| OTP‑11 | Internationalization – right‑to‑left layout | 1. Set device locale to Arabic (ar). 2. Open OTP screen. | OTP fields flow from right to left, cursor starts at rightmost field. | P2 |
| OTP‑12 | Session persistence after verification | 1. Complete OTP verification. 2. Background the app, then restore. | User remains on post‑verification screen; no forced re‑entry of OTP. | P2 |
| OTP‑13 | OTP request throttling | 1. 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‑14 | Handling of non‑numeric input | 1. Tap an OTP field and paste “abc123”. | Field rejects non‑numeric characters, retains only digits, or shows validation error. | P2 |
| OTP‑15 | Cut‑copy‑paste behavior | 1. 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‑16 | Autofill 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‑17 | Handling of leading zeros | 1. Receive OTP “001234”. 2. Enter exactly as shown. | Verification succeeds; leading zeros are not trimmed. | P1 |
| OTP‑18 | OTP 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‑19 | Concurrent 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‑20 | Error state persistence | 1. 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
- Install the latest Flutter build (
flutter channel stable && flutter upgrade). - Enable Developer options and USB debugging on your Android/iOS device.
- For iOS, pair the device with Xcode and trust the development certificate.
- Install an SMS simulator (e.g., Android Emulator’s extended controls > SMS, or use a service like Firebase App Distribution’s test phone numbers).
2. Test harness
- Run the app in debug mode:
flutter run. - Keep the Flutter DevTools open to monitor widget rebuilds and network calls via the Observatory.
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
- Turn on TalkBack (Android) or VoiceOver (iOS).
- Navigate through the OTP screen using swipe gestures.
- Verify that each field announces its purpose, current value, and that the “Verify” button is reachable.
- Use the Accessibility Scanner (Android) or Accessibility Inspector (iOS) to confirm contrast and touch target size (≥ 48 dp).
5. Security checks
- Enable verbose logging (
flutter run -v). - Perform a successful OTP flow and inspect logcat/console for the OTP string.
- Use a network interception tool (e.g., Charles Proxy) to confirm that the OTP is transmitted over HTTPS only.
6. Network variability
- Use the Android Emulator’s network settings to simulate 2G, 3G, or LTE latency and packet loss.
- Repeat OTP‑05 and OTP‑06 under each profile to ensure timeout handling works.
7. Documentation
- Record a short video of each failure case for the bug report.
- Capture screenshots of UI states (e.g., expired timer, resend button).
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
- Curious persona – taps every visible element, including the resend button multiple times, long‑presses fields to trigger context menus, and attempts to paste from clipboard after clearing the field.
- Impatient persona – rapidly taps “Send Code” and “Verify” without waiting for network responses, often triggering duplicate requests or out‑of‑order state updates.
- Novice persona – enters digits slowly, frequently uses the backspace key, and may leave fields partially filled before navigating away.
- Adversarial persona – deliberately submits non‑numeric strings, extremely long pastes, or attempts to inject JavaScript‑like snippets into the OTP fields (if the app uses a web view for fallback).
- Elderly persona – exhibits longer press durations, may miss the small tap targets, and relies heavily on accessibility features like font scaling.
- Accessibility persona – enables TalkBack/VoiceOver, navigates via swipe gestures, and expects appropriate semantics labels and focus order.
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:
- 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.
- 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.
- 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). - 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