How to Test OTP Verification on React Native (Complete Guide)

How to Test Otp Verification on React Native (Complete Guide)

March 08, 2026 · 16 min read · How-To Guides

How to Test Otp Verification on React Native (Complete Guide)

Testing one‑time passcode (OTP) flows in React Native applications is a critical quality gate because a broken verification step blocks onboarding, payments, account recovery, and any feature that relies on phone‑number identity. When OTP verification fails in production you see abandoned sign‑ups, frustrated users, and increased support load. This guide walks you through why OTP matters, what typically breaks, a comprehensive test matrix, manual and automated techniques, accessibility and security checks, and how autonomous persona‑driven exploration surfaces bugs that scripted tests miss. Every section contains concrete steps, code samples, or command‑line snippets you can copy into a React Native project today.

1. Why OTP Verification Deserves Dedicated Testing

OTP verification is often the first real interaction a user has with your backend after they supply a phone number. The flow typically involves:

  1. User enters a phone number and taps Send Code.
  2. Backend generates a cryptographically random 6‑digit code, stores it (often with TTL), and sends it via SMS or a push‑based provider.
  3. User inputs the code into an OTP field.
  4. Client validates the code against the backend, receives a token, and proceeds.

If any of those steps misbehave—network timeout, code expiration, incorrect UI handling, or accessibility barrier—the user cannot continue. Unlike static UI components, OTP flows are stateful, time‑sensitive, and depend on external services, making them prone to race conditions, flaky tests, and edge‑case bugs that only appear under load or with specific carrier delays.

2. Common Failure Modes in Production

Failure CategoryTypical SymptomRoot Cause
Network latency“Sending…” spinner never resolves, user taps backAPI timeout not handled, no retry UI
Code expiryValid code rejected after 30 sClient uses stale TTL, server enforces shorter window
Incorrect maskingOTP shows plain numbers in screenshots/logsTextInput secureTextEntry missing or overridden
Focus lossKeyboard dismisses after each digit, forcing re‑tapAuto‑focus logic broken when using react-native-otp-input
International formatting+1 (555) 123‑4567 rejected as invalidPhone‑number parser not E.164‑aware
AccessibilityTalkBack reads “edit text” instead of “OTP field 1 of 6”Missing accessibilityLabel or accessibilityValue
Security leakageCode appears in React Native debugger logsconsole.log of OTP left in dev build
Duplicate submissionUser resends code, receives two different codes, first expiresNo debounce on resend button, server allows multiple active codes
Error‑state UIError toast disappears before user reads itToast duration too short, not tied to user action

Understanding these patterns helps you build a test matrix that covers both happy paths and the ways the flow can break.

3. Complete Test Matrix for OTP Verification

The following sections.

Test IDDescriptionPreconditionsStepsExpected ResultPass/Fail Criteria
OTP‑01Happy path: send, receive, verifyUser has valid phone number, network ok1. Enter number → Send 2. Wait for SMS (simulate) 3. Input 6‑digit code → VerifyBackend returns auth token, navigation proceeds to home screenToken present, screen changes
OTP‑02Invalid code rejectionSame as OTP‑01Same steps, but input wrong code (e.g., 000000)Backend returns 400 invalid‑otp, UI shows error toastError message displayed, token absent
OTP‑03Code expirySame as OTP‑01, but wait > TTL (Simulate 90‑second delay) then input codeBackend returns 410 expired, UI shows “code expired”Proper expiry handling
OTP‑04Resend cooldownSame as OTP‑011. Send code 2. Immediately tap Resend 3. Wait cooldown periodResend button disabled for 30 s, then enabledButton state reflects cooldown
OTP‑05Network failure on sendDisable Wi‑Fi/cellularEnter number → SendApp shows “Unable to send code, try again” and does not crashGraceful error UI, no exception
OTP‑06Network failure on verifyDisable after code receivedInput correct code → VerifyApp shows verification failed, offers retryNo crash, retry possible
OTP‑07International number formattingInput number with spaces, dashes, parenthesesEnter “(555) 123‑4567” → SendBackend receives E.164 “+15551234567”, SMS sentParser normalizes correctly
OTP‑08OTP field secure entryScreen with OTPInput componentEnable “Show passwords” in developer optionsDigits remain obscured (dots)secureTextEntry works
OTP‑09Focus traversalSix‑box OTP inputFill first five boxes, then tap sixthCursor moves to next box automatically, keyboard stays openFocus management correct
OTP‑10Accessibility labelTalkBack enabledNavigate to OTP fieldTalkBack reads “OTP field 1 of 6, edit text”Proper accessibilityLabel/accessibilityValue
OTP‑11Error toast durationTrigger invalid codeSubmit wrong codeToast remains visible ≥ 4 s or until dismissUser can read message
OTP‑12Duplicate send preventionRapid double tap on SendTap Send twice within 500 msOnly one request sent to backendDebounce/throttle in place
OTP‑13Leak in logsEnable remote JS debuggingPerform OTP flowNo OTP appears in console or React Native DevToolsNo console.log of OTP
OTP‑14Backend rate limitingSimulate many rapid attemptsSend code 5 times in 10 sBackend returns 429 too‑many‑requests, UI shows rate‑limit messageProper handling of 429
OTP‑15Autofill from SMS (Android)Android ≥ 18 with SMS Retriever APIReceive SMS with app‑specific hashOTP auto‑fills into field without manual entryAutofill works, security hash verified

You can copy this table into a test‑management tool (e.g., TestRail, Zephyr) and map each ID to automated test cases.

4. Manual Testing Step‑by‑Step

Even with automation, a manual sanity check catches UI quirks and device‑specific behavior. Follow this checklist on a physical Android/iOS device (or emulator/simulator) before each release.

  1. Setup
  1. Send Code
  1. Capture OTP
  1. Input and Verify
  1. Error Paths
  1. Accessibility
  1. Security Check
  1. Cleanup

Document any deviations from the expected results in a bug ticket, referencing the test‑matrix ID (e.g., OTP‑07). Manual testing is especially valuable for checking device‑specific SMS delivery delays and for validating that custom OTP UI components render correctly on different screen densities.

5. Automated Unit Testing with Jest and React Native Testing Library

Unit tests validate the logic that prepares the request, handles responses, and updates UI state without relying on a real SMS gateway. The goal is to test the *client‑side* state machine.

5.1. Setup


# Add dependencies
yarn add -D jest @testing-library/react-native @testing-library/jest-native
# If using Expo
expo install @testing-library/react-native

Add a jest.setup.js file:


import '@testing-library/jest-native/extend-expect';

5.2. Mocking the API Layer

Create a mock for your OTP service (api/otpService.js):


// __mocks__/api/otpService.js
export const sendOtp = jest.fn();
export const verifyOtp = jest.fn();

In your component test, jest will automatically use the mock if the file resides under __mocks__.

5.3. Component Example

Assume a simple OTP screen:


// src/screens/OtpScreen.js
import React, { useState } from 'react';
import { View, TextInput, Button, Text, ActivityIndicator } from 'react-native';
import { sendOtp, verifyOtp } from '../api/otpService';

export default function OtpScreen({ phoneNumber }) {
  const [otp, setOtp] = useState('');
  const [status, setStatus] = useState('idle'); // idle, sending, verifying, success, error
  const [error, setError] = setError('');

  const handleSend = async () => {
    setStatus('sending');
    try {
      await sendOtp({ phoneNumber });
      setStatus('idle');
    } catch (e) {
      setStatus('error');
      setError('Failed to send code');
    }
  };

  const handleVerify = async () => {
    setStatus('verifying');
    try {
      await verifyOtp({ phoneNumber, code: otp });
      setStatus('success');
    } catch (e) {
      setStatus('error');
      setError('Invalid or expired code');
    }
  };

  return (
    <View style={{ padding: 20 }}>
      <Text>Enter the 6‑digit code sent to {phoneNumber}</Text>
      {status === 'sending' && <ActivityIndicator />}
      {status === 'error' && <Text style={{ color: 'red' }}>{error}</Text>}
      <TextInput
        placeholder="______"
        value={otp}
        onChangeText={setOtp}
        maxLength={6}
        keyboardType="number-pad"
        secureTextEntry
        autoFocus
        editable={status !== 'success'}
      />
      {status === 'idle' && (
        <Button title={otp.length === 6 ? 'Verify' : 'Send Code'} onPress={otp.length === 6 ? handleVerify : handleSend} disabled={status !== 'idle'} />
      )}
    </View>
  );
}

5.4. Test Suite


// __tests__/OtpScreen.test.js
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import OtpScreen from '../src/screens/OtpScreen';
import { sendOtp, verifyOtp } from '../src/api/otpService';

describe('OtpScreen', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('sends OTP when button pressed with empty field', async () => {
    const { getByLabelText, getByText } = render(<OtpScreen phoneNumber="+15551234567" />);
    const sendBtn = getByText(/send code/i);
    fireEvent.press(sendBtn);
    expect(sendOtp).toHaveBeenCalledWith({ phoneNumber: '+15551234567' });
  });

  test('shows sending indicator while waiting', async () => {
    sendOtp.mockReturnValue(new Promise(() => {})); // pending promise
    const { getByTestId } = render(<OtpScreen phoneNumber="+15551234567" />);
    fireEvent.press(getByText(/send code/i));
    expect(getByTestId('activity-indicator')).toBeTruthy();
  });

  test('transitions to success on correct OTP', async () => {
    verifyOtp.mockResolvedValueOnce();
    const { getByLabelText, getByText } = render(<OtpScreen phoneNumber="+15551234567" />);
    const codeInput = getByLabelText(/enter the 6‑digit code/i);
    fireEvent.changeText(codeInput, '123456');
    fireEvent.press(getByText(/verify/i));
    await waitFor(() => {
      expect(getByText(/success/i)).toBeTruthy();
    });
    expect(verifyOtp).toHaveBeenCalledWith({ phoneNumber: '+15551234567', code: '123456' });
  });

  test('shows error on invalid OTP', async () => {
    verifyOtp.mockRejectedValueOnce(new Error('bad otp'));
    const { getByLabelText, getByText } = render(<OtpScreen phoneNumber="+15551234567" />);
    const codeInput = getByLabelText(/enter the 6‑digit code/i);
    fireEvent.changeText(codeInput, '000000');
    fireEvent.press(getByText(/verify/i));
    await waitFor(() => {
      expect(getByText(/invalid or expired/i)).toBeTruthy();
    });
  });
});

Key points

Run the suite with yarn test. Aim for > 90 % line coverage on the OTP screen; uncovered lines often indicate missing error handling.

6. End‑to‑End Testing with Detox

Detox exercises the compiled native binary on a device or emulator, making it ideal for verifying that the OTP flow works with real navigation, animations, and native modules (e.g., SMS Retriever).

6.1. Install Detox


yarn add -D detox
detox init -r jest
# For Android
yarn add -D jest-circus

Update package.json:


"detox": {
  "test-runner": "jest",
  "configs": {
    "android.emu.debug": {
      "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
      "build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug",
      "type": "android.emulator",
      "device": {
        "avdName": "pixel_5_api_33"
      }
    }
  }
}

6.2. Writing the Test

Create e2e/otpFlow.test.js:


describe('OTP verification flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('sends code, receives via SMS Retriever, verifies and navigates home', async () => {
    // 1. Enter phone number
    await element(by.id('phone-input')).typeText('+15551234567');
    await element(by.id('send-code-btn')).tap();

    // 2. Wait for sending indicator to disappear
    await waitFor(element(by.id('sending-spinner')))
      .toNotExist()
      .withTimeout(10000);

    // 3. Simulate SMS arrival – Detox can push a test SMS via ADB
    await device.sendSMS({ phoneNumber: '+15551234567', message: `Your code is 987654` });

    // 4. Expect auto‑fill (if using SMS Retriever) or manual entry
    await waitFor(element(by.id('otp-input-1')))
      .toHaveValue('9')
      .withTimeout(5000);
    // If autofill not enabled, fill manually:
    // await element(by.id('otp-input')).typeText('987654');

    // 5. Press verify
    await element(by.id('verify-btn')).tap();

    // 6. Wait for home screen
    await waitFor(element(by.id('home-screen')))
      .toExist()
      .withTimeout(10000);
  });

  it('shows error on wrong code', async () => {
    await element(by.id('phone-input')).typeText('+15551234567');
    await element(by.id('send-code-btn')).tap();
    await waitFor(element(by.id('sending-spinner'))).toNotExist().withTimeout(10000);
    await device.sendSMS({ phoneNumber: '+15551234567', message: `Your code is 000000` });
    await element(by.id('otp-input')).typeText('111111'); // wrong
    await element(by.id('verify-btn')).tap();
    await waitFor(element(by.id('error-toast')))
      .toHaveText(/invalid or expired/i)
      .withTimeout(5000);
  });
});

Explanation of key commands

Run the suite:


detox test -c android.emu.debug

Detox will spin up the emulator, install the app, and execute the scenarios. Adjust the timeout values based on your network simulation (you can also throttle with adb shell netcfg or Facebook’s network-throttling tool).

6.3. Handling Time‑Sensitive Code Expiry

To test expiry, advance the device clock or use a mock backend that returns a preset TTL. Example with a mock server (using msw):


// In detox test, before sending code:
await device.sendSMS({ phoneNumber: '+15551234567', message: `Your code is 123456` });
// Wait 70 seconds (if TTL=60s)
await new Promise(r => setTimeout(r, 70000));
await element(by.id('otp-input')).typeText('123456');
await element(by.id('verify-btn')).tap();
// Expect expiry error
await waitFor(element(by.id('error-toast')))
  .toHaveText(/code expired/i)
  .withTimeout(5000);

Detox’s ability to control the device clock (adb shell date) is limited on non‑rooted emulators, so a backend‑controlled TTL is often simpler.

7. Testing with Expo’s Development Tools

If your project uses Expo, you can leverage Expo’s built‑in testing utilities and the Expo Go client for rapid manual checks, plus Expo’s expo-dev-client for custom native modules.

7.1. Unit Tests with Jest (same as section 5)

Expo projects already include Jest configuration; just ensure jest.setup.js is imported.

7.2. Using expo-dev-client for Detox

Create a dev client:


expo install expo-dev-client
expo prebuild -p android

Then follow the Detox steps from section 6, pointing binaryPath to the dev client build (android/app/build/outputs/apk/dev/client-debug.apk). This lets you test OTP flows that rely on native modules not available in the Expo Go sandboxed in the standard Expo SDK (e.g., a custom SMS Retriever module).

7.3. Manual Checks with Expo Go

Expo’s ease of iteration is valuable for early‑stage UI tweaks, but for reliable CI you should still run Detox against a dev client or a bare workflow build.

8. Accessibility Testing (aXe, TalkBack, VoiceOver)

Accessibility bugs in OTP flows often manifest as missing labels or confusing focus order. Automated tools can catch many of these issues early.

8.1. Setting Up @testing-library/react-native with axe-core


yarn add -D @testing-library/react-native @axe-core/react

Create a test file:


// __tests__/OtpScreen.accessibility.test.js
import React from 'react';
import { render } from '@testing-library/react-native';
import { axe, toHaveNoViolations } from '@axe-core/react';
import OtpScreen from '../src/screens/OtpScreen';

expect.extend(toHaveNoViolations);

test('OtpScreen has no accessibility violations', async () => {
  const { container } = render(<OtpScreen phoneNumber="+15551234567" />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Run with yarn test. The rule set includes checks for:

8.2. Manual TalkBack / VoiceOver Verification

  1. Enable TalkBack (Android Settings → Accessibility → TalkBack) or VoiceOver (iOS Settings → Accessibility → VoiceOver).
  2. Navigate to the OTP screen.
  3. Listen for each field: it should announce “OTP field 1 of 6, edit text, blank”.
  4. When a digit is entered, the announcement should update to include the entered value (e.g., “OTP field 1 of 6, edit text, 5”).
  5. Ensure that moving focus with swipe gestures does not skip any box and that the keyboard remains visible when expected.

If any field is silent or announces incorrectly, add appropriate props:


<TextInput
  testID={`otp-input-${index}`}
  value={otp[index] || ''}
  onChangeText={val => handleChange(index, val)}
  maxLength={1}
  keyboardType="number-pad"
  secureTextEntry
  autoFocus={index === 0}
  accessibilityLabel={`OTP field ${index + 1} of ${total}`}
  accessibilityValue={otp[index] ?? ''}
/>

8.3. Color Contrast

Use the react-native-contrast-checker library or manually verify with the WCAG 2.1 AA requirement (≥ 4.5:1 for normal text). OTP boxes often have a thin border; ensure the border color contrasts sufficiently with the background.

9. Security and Privacy Considerations

OTP handling touches on several security domains: transmission secrecy, storage, replay resistance, and leakage via logs or screenshots.

9.1. Transport Security

Add a test that asserts the request URL starts with https://:


// jest mock for fetch
global.fetch = jest.fn();
test('sendOtp uses HTTPS', async () => {
  await sendOtp({ phoneNumber: '+15551234567' });
  expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://'));
});

9.2. Code Storage

Never persist the raw OTP in AsyncStorage or SharedPreferences. Keep it only in memory (Redux state, React context, or a closure). Write a unit test that attempts to read the storage after verification and expects null.


import AsyncStorage from '@react-native-async-storage/async-storage';
jest.mock('@react-native-async-storage/async-storage');

test('OTP not stored after verification', async () => {
  AsyncStorage.getItem.mockResolvedValue(null);
  await verifyOtp({ phoneNumber: '+15551234567', code: '654321' });
  expect(AsyncStorage.getItem).not.toHaveBeenCalledWith('otp');
});

9.3. Replay Attack Mitigation

The backend should mark a code as used after a successful verification. To test the client’s handling of a reused code:

  1. Mock the verify endpoint to return success on the first call, then a 400 code already used on subsequent calls.
  2. In the test, call verify twice with the same OTP and assert that the second attempt shows an error toast.

verifyOtp
  .mockResolvedValueOnce() // first success
  .mockRejectedValueOnce(new Error('code already used'));

test('prevents OTP reuse', async () => {
  const { getByLabelText, getByText } = render(<OtpScreen phoneNumber="+15551234567" />);
  const input = getByLabelText(/enter the 6‑digit code/i);
  fireEvent.changeText(input, '123456');
  fireEvent.press(getByText(/verify/i));
  await waitFor(() => expect(getByText(/success/i)).toBeTruthy());

  // second attempt
  fireEvent.changeText(input, '123456');
  fireEvent.press(getByText(/verify/i));
  await waitFor(() => expect(getByText(/already used/i)).toBeTruthy());
});

9.4. Screenshot Protection (Android)

Enable the FLAG_SECURE window flag to prevent OTP from appearing in screenshots or recent‑apps thumbnails.


import { PermissionsAndroid } from 'react-native';
import { NativeModules } from 'react-native';

const { RNWindowManager } = NativeModules;

useEffect(() => {
  (async () => {
    try {
      await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.SYSTEM_ALERT_WINDOW);
      RNWindowManager.setSecureFlag(true);
    } catch (e) {
      console.warn('Unable to set secure flag', e);
    }
  })();
}, []);

Write a test that attempts to capture a screenshot via adb shell screencap and asserts that the OTP region is blank (this is more of a manual check, but you can automate with device farm tools that compare pixel values).

9.5. Logging Sanitization

Add a ESLint rule to forbid console.log of variables named otp, code, or passcode. Example .eslintrc.js:


rules: {
  'no-console': ['error', { allow: ['warn', 'error'] }],
  'no-restricted-syntax': [
    'error',
    {
      selector: 'CallExpression[callee.object.name="console"][callee.property.name="log"]',
      message: 'Do not log OTP or sensitive data',
    },
  ],
};

Run yarn lint as part of CI to catch accidental leaks.

10. Autonomous, Persona‑Driven Exploration with SUSA

Scripted tests excel at verifying known paths, but they cannot anticipate every way a real user might interact with an OTP screen—especially when users are distracted, impatient, or use assistive technology in unconventional ways. SUSA (the autonomous QA platform) explores the app by simulating a variety of user personas, each with distinct behavior profiles, and automatically discovers issues that static test suites miss.

10.1. How SUSA Works with React Native

  1. Upload – Provide the APK (or a testflight build) and the base URL of your backend.
  2. Persona Engine – SUSA spins up virtual users:
  1. Exploration – The agent drives the UI, captures network traffic, monitors logs, and records UI state transitions.
  2. Assertion‑Free Oracles – It flags crashes, ANRs, unhandled promise rejections, accessibility violations (via automated axe scans), and security red flags (e.g., OTP appearing in logs).
  3. Regression Script Generation – After a run, SUSA outputs ready‑to‑run Appium (Android) and Playwright (Web) scripts that reproduce the discovered flows, letting you add them to your CI pipeline.

10.2. Configuring a SUSA Run for OTP Verification

Create a susa-config.json in your repo:


{
  "app": "./android/app/build/outputs/apk/debug/app-debug.apk",
  "backend": "https://api.example.com",
  "personas": ["curious", "impatient", "novice", "accessibility", "adversarial"],
  "maxDepth": 8,
  "timePerSessionMs": 30000,
  "outputDir": "./susa-reports",
  "generateScripts": true,
  "scriptFormats": ["appium", "playwright"]
}

Run the agent:


npx susatest-agent run --config susa-config.json

During the exploration, SUSA might notice:

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