How to Test Forgot Password on iOS (Complete Guide)

How to Test Forgot Password on iOS (Complete Guide): Why It Matters

March 04, 2026 · 15 min read · How-To Guides

How to Test Forgot Password on iOS (Complete Guide): Why It Matters

Testing the forgot‑password flow is a high‑risk area because it directly touches user authentication, account recovery, and data privacy. A broken reset mechanism can lead to account takeover, credential leakage, or denial‑of‑service for legitimate users. In iOS apps the flow often involves a combination of UI elements (text fields, buttons, links), deep‑link handling, backend API calls, and sometimes biometric fallback. When any of these pieces misbehave, users experience friction, support costs rise, and the app’s reputation suffers.

A systematic approach—starting with a clear test matrix, moving through manual verification, then layering automation and autonomous exploration—helps catch the subtle bugs that only appear under specific conditions (e.g., VoiceOver enabled, intermittent network, or when a power user pastes a very long token). This guide walks you through each step, provides concrete iOS‑specific examples, and shows how a persona‑driven autonomous tool like SUSA can surface issues that scripted tests never think to try.

---

How to Test Forgot Password on iOS (Complete Guide): Building the Test Matrix

A solid test matrix separates the happy path from error conditions, edge cases, accessibility checks, and security/privacy concerns. Below is a comprehensive matrix you can copy into a test‑plan spreadsheet. Each row includes a test ID, description, expected result, and notes on iOS‑specific considerations.

Test IDCategoryDescriptionExpected ResultiOS‑Specific Notes
FPIOS‑01Happy PathUser taps “Forgot Password?” on login screen, enters valid registered email, receives reset link via email, clicks link, sets new password, logs in with new credentials.Reset succeeds, new password works, old password invalidated.Verify that the email composition UI (MFMailComposeViewController or universal link) presents correctly; check that the app returns to foreground after link tap via Universal Links.
FPIOS‑02Happy PathSame as FPIOS‑01 but user initiates flow from a Settings screen rather than login.Same outcome as FPIOS‑01.Ensure deep link handling works from any entry point (e.g., myapp://reset?token=…).
FPIOS‑03Error PathUser enters an email that is not registered in the backend.App shows a generic “If the email exists, you’ll receive instructions” message (no enumeration).Backend must return same HTTP status (200) and UI must not reveal existence.
FPIOS‑04Error PathUser leaves email field blank and taps Submit.Inline validation shows “Email is required”.Use UITextFieldDelegate shouldChangeCharactersIn to enforce real‑time check; ensure error label is accessible (isAccessibilityElement = true).
FPIOS‑05Error PathUser enters a malformed email (e.g., “user@”).Inline validation shows “Please enter a valid email address”.Regex should allow iOS‑accepted characters; test with Unicode domains.
FPIOS‑06Error PathNetwork request times out (simulate with Network Link Conditioner set to 100% loss).App displays a retryable network error, does not crash, and retains entered email.Use URLSession with timeout; verify that UI does not dismiss the screen automatically.
FPIOS‑07Error PathBackend returns 429 Too Many Requests (rate limit).App shows a friendly “Too many attempts, try again later” and disables resend button for a cool‑down period.Ensure the UI respects the Retry-After header if present.
FPIOS‑08Edge CaseUser pastes a 500‑character string into the email field.Field accepts paste but validation rejects as invalid email; UI does not hang.Check that UITextField does not exceed its intrinsic content size causing layout glitches.
FPIOS‑09Edge CaseUser triggers the flow while VoiceOver is ON and navigates using swipe gestures.All controls are announced correctly; focus moves to error messages when they appear.Test with UIAccessibility.isVoiceOverRunning; ensure accessibilityHint is set on the submit button.
FPIOS‑10Edge CaseUser has Dynamic Type set to largest accessibility size.Layout scales, no clipping, buttons remain tappable.Use UIFontMetrics for scaling; verify with Xcode’s Environment Overrides.
FPIOS‑11Edge CaseUser enables Reduce Motion; the app uses a modal transition for the reset screen.Transition respects reduced motion (fade or slide disabled).Check UIViewPropertyAnimator or UIView.animate with UIView.AnimationOptions.allowUserInteraction.
FPIOS‑12Security/PrivacyReset token appears in URL bar of Safari when universal link opens.Token is short‑lived (≤15 min) and transmitted over HTTPS only; no logging of token in plain text.Use OSLog with .private to avoid token leakage; verify with network sniffing (Charles).
FPIOS‑13Security/PrivacyApp allows unlimited reset requests from the same device/IP in a short window.Server enforces rate limiting (e.g., 5 requests per 15 min) and app shows appropriate feedback.Test with a script that fires rapid requests; ensure no account enumeration via timing differences.
FPIOS‑14Security/PrivacyAfter password reset, old sessions remain valid.All existing tokens are invalidated; user must re‑login on all devices.Verify backend revokes refresh tokens; test by logging in on a second device before reset.
FPIOS‑15Security/PrivacyReset screen does not mask password entry (shows characters).New password fields are isSecureTextEntry = true.Confirm with Accessibility Inspector that the field is marked as secure.

*Use this matrix as a baseline; add project‑specific variants (e.g., social‑login linked accounts, corporate SSO, or OTP‑based reset).*

---

How to Test Forgot Password on iOS (Complete Guide): Manual Step‑by‑Step Testing Procedure

Manual testing remains valuable for exploratory checks, especially when validating accessibility and subtle UI glitches. Follow this procedure on a physical device running the latest iOS version supported by your app (simulator can miss certain hardware‑related behaviors like pasteboard restrictions).

  1. Prepare the Environment
  1. Happy Path Verification
  1. Error Path Checks
  1. Edge‑Case Exploration
  1. Security & Privacy Spot‑Checks
  1. Document Findings

---

How to Test Forgot Password on iOS (Complete Guide): Automated Testing with XCTest / XCUITest

XCUITest is Apple’s UI testing framework and integrates naturally with Xcode CI pipelines. It allows you to drive the app exactly the same interactions a user performs while asserting on UI elements and network mocks. Below is a practical setup and sample tests.

Setting Up the Test Target

  1. In Xcode, select File → New → Target → UI Testing Bundle.
  2. Ensure the bundle’s Info.plist contains UITesting key set to YES.
  3. Add a helper class to manage app state:

import XCTest

final class ForgotPasswordUITestHelper {
    let app: XCUIApplication
    
    init() {
        app = XCUIApplication()
        app.launchArguments.append("-UI_TESTING")
        app.launchEnvironment["UI_TESTING"] = "1"
    }
    
    func launch() {
        app.launch()
    }
    
    func term() {
        app.terminate()
    }
}
  1. Create a mock networking layer (using URLProtocol) that returns predefined JSON for the reset API. This lets you test success, validation errors, and rate‑limit responses without hitting a real server.

Sample XCTest Code for Happy Path


import XCTest

final class ForgotPasswordHappyPathTests: XCTestCase {
    var helper: ForgotPasswordUITestHelper!
    
    override func setUp() {
        super.setUp()
        helper = ForgotPasswordUITestHelper()
        helper.launch()
        continueAfterFailure = false
    }
    
    override func tearDown() {
        helper.term()
        super.tearDown()
    }
    
    func testResetPasswordSuccess() {
        let emailField = helper.app.textFields["Email"]
        XCTAssertTrue(emailField.waitForExistence(timeout: 5))
        
        emailField.tap()
        emailField.typeText("user@example.com")
        
        let submitBtn = helper.app.buttons["Submit"]
        XCTAssertTrue(submitBtn.isEnabled)
        submitBtn.tap()
        
        // Expect a success alert
        let successAlert = helper.app.staticTexts["Check your inbox for the reset link"]
        XCTAssertTrue(successAlert.waitForExistence(timeout: 5))
        
        // Simulate tapping the universal link (we deep-link directly)
        helper.app.launchEnvironment["RESET_TOKEN"] = "valid-token-123"
        helper.app.terminate()
        helper.launch()
        
        let newPwd = helper.app.secureTextFields["New Password"]
        let confirmPwd = helper.app.secureTextFields["Confirm Password"]
        XCTAssertTrue(newPwd.waitForExistence(timeout: 5))
        newPwd.tap()
        newPwd.typeText("NewP@ssw0rd!")
        confirmPwd.tap()
        confirmPwd.typeText("NewP@ssw0rd!")
        
        helper.app.buttons["Reset Password"].tap()
        
        // After reset, we should be back at login screen
        let loginBtn = helper.app.buttons["Log In"]
        XCTAssertTrue(loginBtn.waitForExistence(timeout: 5))
        
        // Try login with new password
        let loginEmail = helper.app.textFields["Email"]
        loginEmail.tap()
        loginEmail.typeText("user@example.com")
        let loginPwd = helper.app.secureTextFields["Password"]
        loginPwd.tap()
        loginPwd.typeText("NewP@ssw0rd!")
        helper.app.buttons["Log In"].tap()
        
        // Verify we reach the home screen (customize identifier)
        let homeTab = helper.app.tabBars.buttons["Home"]
        XCTAssertTrue(homeTab.waitForExistence(timeout: 5))
    }
}

Sample XCTest Code for Error Paths


func testEmptyEmailShowsValidation() {
    helper.launch()
    let submit = helper.app.buttons["Submit"]
    submit.tap() // try to submit with empty field
    
    let error = helper.app.staticTexts["Email is required"]
    XCTAssertTrue(error.waitForExistence(timeout: 3))
}
    
func testRateLimitShowsRetry() {
    // Configure mock to return 429 on the third call
    helper.launch()
    let email = helper.app.textFields["Email"]
    email.tap()
    email.typeText("test@example.com")
    
    // First two attempts succeed (mock returns 200)
    for _ in 0..<2 {
        helper.app.buttons["Submit"].tap()
        XCTAssertTrue(helper.app.staticTexts["Check your inbox"].waitForExistence(timeout: 3))
        // reset mock for next call
    }
    
    // Third attempt – should hit rate limit
    helper.app.buttons["Submit"].tap()
    let limitMsg = helper.app.staticTexts["Too many attempts, try again later"]
    XCTAssertTrue(limitMsg.waitForExistence(timeout: 3))
    // Ensure submit button is disabled
    XCTAssertFalse(helper.app.buttons["Submit"].isEnabled)
}

Handling Asynchronous Network Calls

Running Tests in CI

Add a step to your fastlane or GitHub Actions workflow:


xcodebuild test \
  -workspace MyApp.xcworkspace \
  -Scheme MyAppUITests \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
  -only-testing:ForgotPasswordHappyPathTests/testResetPasswordSuccess \
  -only-testing:ForgotPasswordErrorPathTests/testEmptyEmailShowsValidation \
  -only-testing:ForgotPasswordErrorPathTests/testRateLimitShowsRetry \
  | xcpretty

---

How to Test Forgot Password on iOS (Complete Guide): Automated Testing with Appium (iOS)

Appium enables cross‑platform UI automation using the WebDriver protocol. It’s handy when you want to run the same script against real devices, simulators, or even cloud farms (Sauce Labs, BrowserStack). Below is a concise guide to setting up Appium for iOS forgot‑password testing, plus a parameterized script that can emulate different user personas.

Installing Appium and Dependencies


# Install Node.js (if not present)
brew install node

# Install Appium server globally
npm install -g appium

# Install the Apple‑specific driver
appium driver install xcuitest

# Install client libraries (choose your language)
# Python example:
pip install Appium-Python-Client
# JavaScript example:
npm install webdriverio appium

Ensure you have Xcode command‑line tools and the WebDriverAgent project built:


# After installing the driver, bootstrap WebDriverAgent
appium driver install xcuitest --force
# Then start Appium server:
appium

Sample Appium Script (Python)


import time
import unittest
from appium import webdriver
from appium.options.ios import XCUITestOptions

class ForgotPasswordAppiumTest(unittest.TestCase):
    def setUp(self):
        options = XCUITestOptions()
        options.platform_name = "iOS"
        options.platform_version = "17.2"
        options.device_name = "iPhone 15"
        options.app = "/path/to/MyApp.ipa"   # or bundle ID for simulator
        options.automation_name = "XCUITest"
        # Disable animation for faster runs
        options.set_capability("disableAnimations", True)
        self.driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

    def tearDown(self):
        self.driver.quit()

    def test_reset_password_with_persona(self, persona="novice"):
        # Persona‑based data – adjust typing speed, error tolerance, etc.
        persona_data = {
            "novice": {"email": "novice@example.com", "pwd": "Novice123!", "type_delay": 0.3},
            "power": {"email": "poweruser@example.com", "pwd": "P0w3r!$", "type_delay": 0.05},
            "impatient": {"email": "imp@example.com", "pwd": "Imp!23", "type_delay": 0.01},
        }
        data = persona_data.get(persona, persona_data["novice"])

        driver = self.driver
        # Wait for login screen
        email_field = driver.find_element(by="accessibility id", value="Email")
        email_field.click()
        email_field.send_keys(data["email"])

        # Simulate a typo for novice persona
        if persona == "novice":
            email_field.send_keys("m")  # accidental extra char
            time.sleep(0.2)
            email_field.clear()
            email_field.send_keys(data["email"])

        submit = driver.find_element(by="accessibility id", value="Submit")
        submit.click()

        # Wait for success toast
        toast = driver.find_element(by="xpath", value="//XCUIElementTypeStaticText[@name='Check your inbox']")
        self.assertTrue(toast.is_displayed())

        # Deep‑link simulation – set env var to trigger reset screen directly
        driver.terminate_app("com.example.myapp")
        driver.activate_app("com.example.myapp")
        # Assuming the app reads launch env var RESET_TOKEN
        driver.set_launch_arguments(["-RESET_TOKEN", "test-token-abc"])

        # Fill new password
        new_pwd = driver.find_element(by="accessibility id", value="New Password")
        new_pwd.click()
        new_pwd.send_keys(data["pwd"])
        confirm_pwd = driver.find_element(by="accessibility id", value="Confirm Password")
        confirm_pwd.click()
        confirm_pwd.send_keys(data["pwd"])

        driver.find_element(by="accessibility id", value="Reset Password").click()

        # Verify back to login
        login_btn = driver.find_element(by="accessibility id", value="Log In")
        self.assertTrue(login_btn.is_displayed())

        # Login with new credentials
        email_field.clear()
        email_field.send_keys(data["email"])
        pwd_field = driver.find_element(by="accessibility id", value="Password")
        pwd_field.send_keys(data["pwd"])
        driver.find_element(by="accessibility id", value="Log In").click()

        # Home screen indicator
        home_tab = driver.find_element(by="accessibility id", value="Home")
        self.assertTrue(home_tab.is_displayed())

if __name__ == "__main__":
    suite = unittest.TestSuite()
    for p in ["novice", "power", "impatient"]:
        suite.addTest(ForgotPasswordAppiumTest("test_reset_password_with_persona", p))
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

Explanation of persona handling:

Running Against Real Devices

Connect a device via USB, trust the computer, and specify its UDID in the capabilities:


options.set_capability("udid", "00008020-001A672C1234001E")

Make sure the device is developer‑enabled and that the app is installed (via ios-deploy or Xcode).

---

How to Test Forgot Password on iOS (Complete Guide): Autonomous, Persona‑Driven Exploration with SUSA

SUSA (SUSATest) is an autonomous QA agent that explores an iOS app without pre‑written scripts. It builds a behavioral model of the app, applies a set of user‑persona profiles, and attempts to achieve goals (like completing a reset flow) while logging any anomalies. Because SUSA does not rely on hard‑coded selectors, it can discover flows that are hidden behind conditional UI, dynamic deep links, or accessibility‑only pathways.

How SUSA Discovers Forgot Password Flows

  1. Entry Point Detection – On launch, SUSA scans the view hierarchy for elements with accessibility labels containing words like “forgot”, “reset”, “recover”, or “password”. It also inspects UITextField placeholders and UIButton titles.
  2. Goal Formulation – Once a candidate is found, SUSA creates a goal: “Reach a screen where a new password can be set and submit it.”
  3. Persona‑Driven Execution – Each run picks a persona (curious, impatient, novice, adversarial, elderly, accessibility, power user). The persona dictates:
  1. Exploration Loop – SUSA interacts with the app, follows links, fills fields, and observes responses (toast, navigation, network calls). It records each state transition and marks dead ends (e.g., a button that does nothing).
  2. Reporting – At the end of a session, SUSA outputs a JSON report with:

Configuring Persona Profiles

You can tune personas via a simple YAML file that SUSA reads at startup. Below is an example that adjusts tap density and enables VoiceOver for the accessibility persona:


personas:
  novice:
    tap_delay_min: 0.2
    tap_delay_max: 0.6
    typo_probability: 0.15
    use_voiceover: false
  impatient:
    tap_delay_min: 0.02
    tap_delay_max: 0.08
    typo_probability: 0.01
    use_voiceover: false
  accessibility:
    tap_delay_min: 0.15
    tap_delay_max: 0.4
    typo_probability: 0.02
    use_voiceover: true
    voiceover_rate: 0.5   # slower speech rate for comprehension
  power:
    tap_delay_min: 0.05
    tap_delay_max: 0.15
    typo_probability: 0.005
    use_voiceover: false
    paste_probability: 0.4   # chance to paste from clipboard

Launch SUSA with:


susatest run \
  --app /path/to/MyApp.ipa \
  --personas-config personas.yaml \
  --goal "reset password" \
  --output-dir ./susa-reports \
  --verbose

Interpreting SUSA Reports

Because SUSA explores without predetermined scripts, it can discover edge cases such as:

---

How to Test Forgot Password on iOS (Complete Guide): Accessibility Testing Specifics for Forgot Password

Accessibility is not a nice‑to‑have; it’s a legal requirement in many jurisdictions and directly impacts user trust. The forgot‑password screen often contains custom controls, placeholder text, and dynamic messages that can break VoiceOver, Switch Control, or Dynamic Type. Below is a focused checklist and the tools you can use to validate each item.

VoiceOver & Switch Control

CheckHow to TestPass Criteria
All interactive elements have a meaningful accessibilityLabelOpen Accessibility Inspector, enable VoiceOver, swipe to each element.Label describes purpose (e.g., “Email text field, is editing”).
Buttons announce state changes (enabled/disabled)Toggle network off, attempt submit, listen for “disabled” announcement.State change is spoken.
Alerts and toasts are announced automaticallyTrigger a validation error, ensure VoiceOver reads the message without needing to move focus.Message spoken immediately.
Custom transitions do not interfere with navigationReduce Motion off, navigate with swipe; ensure focus lands on the next logical element.Focus moves predictably.
The reset link in email opens the app and focus lands on the new‑password fieldUse a test mailbox, tap the universal link, then swipe with VoiceOver.First announced element is the new password field.

Dynamic Type

Contrast & Color

Assistive Touch & Switch Control

Automated Accessibility Checks

You can integrate XCUITest with the XCUIElement property isAccessibilityElement and run the built‑in XCUITestAccessibility template:


func testAccessibilityLabels() {
    let app = XCUIApplication()
    app.launch()
    let emailField = app.textFields["Email"]
    XCTAssertTrue(emailField.isAccessibilityElement)
    XCTAssertEqual(emailField.label, "Email address")
    let submitBtn = app.buttons["Submit"]
    XCTAssertTrue(submitBtn.isAccessibilityElement)
    XCTAssertEqual(submitBtn.label, "Send reset link")
}

Or use the open‑source AccessibilityScanner (fastlane plugin) to generate an HTML report after each UI test run

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