How to Test Profile Editing on Android (Complete Guide)

Profile editing is often the first place users encounter personal data after sign‑up. When the flow works smoothly, users feel confident that the app respects their identity and preferences. A broken

By · March 01, 2026 · 16 min read · How-To Guides

Why Profile Editing Matters on Android

Impact on user trust and retention

Profile editing is often the first place users encounter personal data after sign‑up. When the flow works smoothly, users feel confident that the app respects their identity and preferences. A broken edit screen—whether it fails to save changes, shows stale data, or exposes private fields—immediately erodes trust and can trigger churn, negative reviews, or support tickets. In apps that handle financial, health, or communication data, a faulty profile edit can also lead to regulatory complaints because users expect the ability to correct inaccurate information under GDPR, CCPA, or similar statutes.

Common failure modes in production

Production logs repeatedly reveal a handful of patterns:

  1. Silent saves – the UI shows a success toast, but the backend never receives the request due to a missed network interceptor or an exhausted thread pool.
  2. Stale UI after rotation – configuration changes destroy the Fragment/ViewModel, and the saved state is not restored, causing the user to see old values.
  3. Validation bypass – client‑side checks are omitted or incorrectly implemented, allowing invalid characters (e.g., emojis in a name field that the backend rejects with a 400).
  4. Accessibility gaps – TalkBack skips over the “Save” button because it is decorated with android:importantForAccessibility="no" or lacks a content description.
  5. Race conditions – rapid successive taps on “Save” trigger multiple API calls; the backend processes them out of order, ending with a mixed‑state profile (e.g., new email but old phone number).
  6. Permission‑related dead ends – on Android 13+, requesting the READ_CONTACTS permission to import a profile picture triggers a system dialog that the test automation fails to dismiss, leaving the UI stuck.

Understanding these failure modes shapes the test matrix that follows.

Building a Comprehensive Test Matrix

Dimensions: data types, validation, UI states

A robust matrix treats profile editing as a combination of (i) field type, (ii) validation rule, (iii) UI state, and (iv) contextual factor (network, lifecycle, locale). By crossing these dimensions we generate a finite set of scenarios that can be executed manually or automated.

Field TypeValidation RuleUI StateContextual FactorExpected OutcomeRisk Level
Text (name)Required, max 30 chars, alphanumeric + spaceEmpty fieldOnlineShow inline error, disable SaveHigh
Text (bio)Optional, max 200 chars, no HTMLFilled with valid textAirplane modeSave button enabled, toast “Saving…”, then error toast on timeoutMedium
EmailRequired, RFC‑5322 patternValid emailBackground data restrictedSave succeeds, server returns 200, UI updatesHigh
PhoneOptional, E.164 formatInvalid format (missing +)Low memory (simulated via adb shell am kill)Inline error, Save disabledMedium
Date of birthMust be ≥13 years agoFuture dateLocale change to Arabic (RTL)Error displayed aligned correctlyLow
AvatarOptional, image ≤5 MB, MIME image/*Selected image 6 MBNetwork latency 300 ms (via tc qdisc)Upload fails, show retry optionHigh
GenderSingle‑choice radioPre‑selected “Other”TalkBack enabledFocus lands on radio group, announcement includes stateLow
Save buttonEnabled only when all fields validAll validScreen rotation mid‑editState preserved, Save remains enabledHigh
Cancel buttonAlways enabledAny stateDevice language switched to JapaneseText updates, no loss of entered dataLow

The matrix can be expanded with additional factors such as battery saver mode, dark theme, or font scaling. Each row becomes a test case; the risk level helps prioritize automation effort (high‑risk → automated, low‑risk → spot‑checked manual).

How to prioritize

Start with happy‑path scenarios for each field type (row where validation passes and network is reliable). Then add error‑path rows for each validation rule. Next, layer edge‑case factors (rotation, low memory, locale) onto the happy path to catch state‑loss bugs. Finally, run accessibility and security rows as separate suites because they often require distinct tooling (Accessibility Scanner, MobSF).

Manual Testing Approach

Setting up a test device/emulator

  1. Choose a physical device running Android 10+ (API 29) to exercise manufacturer‑specific OEM skins, or use an emulator with Google Play services for consistent behavior.
  2. Enable Developer options → Stay awake to prevent the screen from locking during long sessions.
  3. Turn on Show taps and Pointer location to visually verify tap accuracy.
  4. Install adb version ≥1.0.82 and grant the test app android.permission.INTERNET and android.permission.POST_NOTIFICATIONS if applicable.
  5. Clear app data (adb shell pm clear com.example.app) before each test run to start from a clean sign‑in state.

Step‑by‑step walkthrough of a typical profile edit flow

  1. Launch the app and navigate to the Profile screen via the bottom navigation or drawer.
  2. Verify that the current values (name, email, avatar) are displayed correctly and that each field is tappable.
  3. Tap the Edit icon (usually a pencil). Confirm that the UI switches to edit mode: input fields become enabled, a Save appears in the action bar, and a Cancel appears opposite.
  4. For each editable field, perform the following sub‑steps:
  1. After editing all fields, tap Save. Watch for a progress indicator, then a success toast. Navigate away and back to the profile screen to confirm the new values persist.
  2. Tap Cancel before saving; ensure the UI reverts to the original values and no network request is sent.
  3. Rotate the device (or trigger adb shell am broadcast -a android.intent.action.CONFIGURATION_CHANGED) while the edit screen is open. Verify that entered text remains and the Save button state is correct.
  4. Simulate a network failure: enable airplane mode after tapping Save. Confirm that an error toast appears, the UI stays in edit mode, and a retry option is presented.
  5. Launch TalkBack and swipe through the edit screen. Ensure each field announces its label, current value, and edit state; the Save button announces “Save button, disabled” when appropriate.
  6. Open Accessibility Scanner from the Play Store, run a scan on the edit screen, and note any contrast or touch‑target failures.

Common pitfalls to watch for

Automated Testing with Espresso and UI Automator

Espresso basics for profile editing

Espresso synchronizes with the UI thread and is ideal for validating view states, performing clicks, and asserting text within the app’s process. To test profile editing, we typically:

  1. Launch the MainActivity via ActivityScenario.
  2. Navigate to the profile fragment using onView(withId(R.id.nav_profile)).perform(click()).
  3. Assert that the current name is displayed: onView(withId(R.id.tv_name)).check(matches(withText(expectedName))).
  4. Click the edit button: onView(withId(R.id.btn_edit)).perform(click()).
  5. Interact with EditTexts: onView(withId(R.id.et_name)).perform(clearText(), typeText(newName)).
  6. Click Save: onView(withId(R.id.btn_save)).perform(click()).
  7. Verify the update: onView(withId(R.id.tv_name)).check(matches(withText(newName))).

Sample Espresso test for happy path


@RunWith(AndroidJUnit4::class)
class ProfileEditTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun editProfile_success() {
        // GIVEN: user is signed in and on profile screen
        onView(withId(R.id.nav_profile)).perform(click())

        // WHEN: entering edit mode and updating name
        onView(withId(R.id.btn_edit)).perform(click())
        onView(withId(R.id.et_name))
            .perform(clearText(), typeText("Ada Lovelace"))
            .perform(closeSoftKeyboard())

        // AND: tapping Save
        onView(withId(R.id.btn_save)).perform(click())

        // THEN: name updated and toast shown
        onView(withId(R.id.tv_name))
            .check(matches(withText("Ada Lovelace")))
        onView(withText(R.string.toast_profile_saved))
            .inRoot(isToast())
            .check(matches(isDisplayed()))
    }
}

*Note:* isToast() is a custom Matcher<Root> that targets transient window types.

Handling dynamic data with IdlingResource

When the Save button triggers a network request via Retrofit, Espresso may proceed before the request finishes, causing flaky assertions. Register an IdlingResource that increments when the request starts and decrements on callbacks:


class NetworkIdlingResource(
    private val apiService: ApiService
) : IdlingResource {

    private var callback: IdlingResource.ResourceCallback? = null
    private var active = false

    init {
        apiService.setRequestListener(object : ApiService.RequestListener {
            override fun onStart() {
                active = true
            }

            override fun onFinish() {
                active = false
                callback?.onTransitionToIdle()
            }
        })
    }

    override fun getName() = "NetworkIdlingResource"

    override fun isIdleNow(): Boolean = !active

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
        this.callback = callback
    }
}

In the test, instantiate the resource with a test double of ApiService and register it via IdlingRegistry.getInstance().register(networkIdlingResource).

UI Automator for system dialogs (permissions, account picker)

Espresso cannot interact with system overlays such as the runtime permission dialog or the Google account chooser. UI Automator bridges that gap:


@Test
public void editProfile_grantContactPermission() {
    // Assume the app requests READ_CONTACTS to import avatar from contacts
    UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

    // Trigger the permission request
    onView(withId(R.id.btn_import_avatar)).perform(click());

    // Wait for the system dialog
    UiObject permissionDialog = device.findObject(
        new UiSelector().textContains("Allow access to contacts?"));
    assertTrue(permissionDialog.waitForExists(5000));

    // Grant permission
    UiObject allowButton = permissionDialog.getChild(
        new UiSelector().text("Allow"));
    assertTrue(allowButton.clickAndWaitForNewWindow(5000));

    // Verify the app proceeds (e.g., contacts picker opens)
    UiObject contactsPicker = device.findObject(
        new UiSelector().descriptionContains("Pick a contact"));
    assertTrue(contactsPicker.waitForExists(5000));
}

Combine UI Automator steps with Espresso assertions in the same test by using UiDevice alongside Espresso.onView.

Flaky test mitigation

Leveraging Kotlin Coroutines and Flow for Testability

Exposing a testable ViewModel

Profile editing logic often lives in a ProfileViewModel that exposes a StateFlow<UiState> and accepts ViewEvents. By injecting a ProfileRepository interface, we can swap in a fake implementation for unit tests.


class ProfileViewModel(
    private val repository: ProfileRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            _uiState.value = repository.getProfile()
        }
    }

    fun onNameChanged(name: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(
                draftName = name,
                isValid = repository.isNameValid(name)
            )
        }
    }

    fun onSaveClicked() {
        viewModelScope.launch {
            val current = _uiState.value
            if (!current.isValid) return@launch
            _uiState.value = current.copy(isSaving = true)
            val result = repository.updateProfile(
                ProfileUpdate(name = current.draftName)
            )
            when (result) {
                is Result.Success -> {
                    _uiState.value = current.copy(
                        isSaving = false,
                        showSuccess = true
                    )
                    savedStateHandle.set("profile", result.getOrThrow())
                }
                is Result.Error -> {
                    _uiState.value = current.copy(
                        isSaving = false,
                        showError = true
                    )
                }
            }
        }
    }
}

The UiState data class holds fields such as draftName, isValid, isSaving, showSuccess, and showError.

Using Turbine to collect Flow emissions

Turbine simplifies collecting a finite number of emissions from a Flow. In a test we can verify that a name change triggers validation and that a save attempt emits the expected state transitions:


@ExperimentalCoroutinesApi
class ProfileViewModelTest {

    private val testDispatcher = UnconfinedTestDispatcher()
    private val fakeRepo = fakeProfileRepository()
    private val viewModel by lazy {
        ProfileViewModel(fakeRepo, SavedStateHandle())
    }

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun editName_flowEmitsCorrectStates() = runTest {
        val turbine = viewModel.uiState.test {
            // Initial loading state
            assertEquals(UiState.Loading, awaitItem())

            // Simulate repo returning a profile
            fakeRepo.setReturnValue(Profile("Ada", "ada@example.com"))
            awaitItem() // now shows loaded state with name Ada

            // Change name to invalid value
            viewModel.onNameChanged("<script>")
            val invalidState = awaitItem()
            assertFalse(invalidState.isValid)
            assertTrue(invalidState.showError == null) // error UI handled elsewhere

            // Change name to valid value
            viewModel.onNameChanged("Ada Lovelace")
            val validState = awaitItem()
            assertTrue(validState.isValid)

            // Click Save
            viewModel.onSaveClicked()
            val savingState = awaitItem()
            assertTrue(savingState.isSaving)

            // Mock repo returns success
            fakeRepo.setUpdateResult(Result.success(Unit))
            val savedState = awaitItem()
            assertTrue(savedState.showSuccess)
            assertEquals("Ada Lovelace", savedState.profile?.name)
        }
        turbine.assertTerminal()
    }
}

This test validates that the ViewModel correctly mediates between UI events and repository calls without touching Android framework classes.

Mocking repository with MockK

For broader behavior (error handling, network latency), MockK lets us define coroutine‑friendly mocks:


mockkObject(NetworkDispatcher) // if using a custom dispatcher
coEvery { repository.getProfile() } returns Profile("Initial", "init@example.com")
coEvery { repository.updateProfile(any()) } returns delay(200) then Result.success(Unit)

By controlling the timing (delay) we can simulate slow networks and assert that the UI shows a spinner for at least the expected duration.

Accessibility Testing (WCAG) for Profile Screens

TalkBack navigation checks

  1. Labeling – Every EditText must have a hint or labelFor attribute; TalkBack should read “Name, edit text”.
  2. State announcements – When a field is invalid, the accessibility delegate should append “error, please enter a valid name”. Implement via accessibilityLiveRegion or by announcing via AccessibilityManager.
  3. Focus order – The sequence should follow visual flow: name → email → bio → avatar → Save → Cancel. Use android:nextFocusDown/up to enforce.
  4. Activate controls – Double‑tap on Save should invoke the same click listener as a sighted tap. Verify via performAccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK).

Automated TalkBack checks can be expressed with the AndroidX Test Espresso Accessibility library:


@GetScreenShotRule
val screenshotRule = ActivityScenarioRule(MainActivity::class.java)

@Test
fun profileEdit_passesAccessibilityChecks() {
    onView(withId(R.id.nav_profile)).perform(click())
    onView(withId(R.id.btn_edit)).perform(click())

    // Run the built‑in checks
    AccessibilityChecks.enable()
}

If any check fails, the test throws an AssertionError detailing the violation (e.g., “View with id et_name has insufficient text contrast”).

Color contrast and touch target size

Automated validation via Android Lint (InvalidPackage detector) can catch hard‑coded dimensions that break scaling.

Manual audit with Accessibility Scanner

  1. Install the Accessibility Scanner app from Play Store.
  2. Open the profile edit screen, tap the floating scanner button, and wait for the overlay.
  3. Review each issue: missing content description, small touch target, low contrast, or missing labeling.
  4. Apply fixes and re‑scan until the scanner reports “No issues found”.

Continuous integration

Add the following to your CI pipeline (GitHub Actions example):


- name: Run accessibility tests
  run: ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest

If the accessibility test suite fails, the build is blocked, preventing regressions.

Security and Privacy Considerations

Protecting PII during edit

Preventing injection via profile fields

Although profile fields are usually free‑form text, they may be concatenated into server‑side queries or logs. Apply output encoding:

Testing logout/re‑auth after sensitive changes

Changing email or phone number should trigger a re‑authentication flow. Write a test that:

  1. Logs in with credential A.
  2. Navigates to profile, changes email to B.
  3. Calls Save.
  4. Verifies that the session token is invalidated (e.g., a subsequent API call returns 401).
  5. Confirms the user is redirected to the login screen.

Automated version with MockWebServer:


@Test
fun emailChange_triggersReauth() = runTest {
    val server = MockWebServer()
    server.enqueue(MockResponse().setResponseCode(200).setBody("""{"token":"old"}"""))
    server.enqueue(MockResponse().setResponseCode(401))
    // configure Retrofit to point to server.url("/")
    // ... perform edit and save ...
    // after save, make a dummy request and assert 401
}

Using MobSF or Drozer for quick scans

Any findings should be triaged and mitigated before release.

Edge Cases That Appear Only in Production

Network interruptions mid‑save

Simulate loss of connectivity after the request has been sent but before the response is received:


# tc to add 30% packet loss on wlan0
adb shell su -c "tc qdisc add dev wlan0 root netem loss 30%"

Run the edit flow; the app should show a retry button and not lose the user’s edits. Verify that the UI remains in edit mode and that the “Save” label changes to “Retry”.

Locale changes and RTL layouts

Switch device language to Hebrew (right‑to‑left) while the edit screen is open. Ensure:

Use adb shell setprop persist.sys.language iw && adb shell setprop persist.sys.region IL && adb reboot to persist the change across reboots.

Low memory/kill scenarios

Android may kill the app’s process when the user switches to a memory‑heavy app (e.g., a game). To test:

  1. Open profile edit, fill in half the fields.
  2. Launch a memory‑hogger: adb shell am start -n com.example.memoryhogger/.MainActivity.
  3. Observe whether the app is killed (check LogCat for Process com.example.app (pid xxx) has died).
  4. Return to the app; the framework should recreate the edit fragment via savedInstanceState. Verify that the entered text persists.

If state is lost, inspect onSaveInstanceState/ViewModel handling.

Multiple account sync conflicts

If the app supports multiple Google accounts and synchronizes profile data via a backend, editing on one device while another device pushes an update can cause conflicts. Test by:

  1. Sign in with account A on device 1, edit name to “Alice”.
  2. Without syncing, sign in with the same account on device 2, edit name to “Alicia”.
  3. Trigger a manual sync on both devices (pull‑to‑refresh on profile screen).
  4. The app should present a merge dialog or automatically apply a “last write wins” strategy with a toast indicating the outcome.

Data migration after app update

When releasing a version that changes the Profile Room schema (e.g., adding a birthday column), the migration must preserve existing edits. Test the migration path:

  1. Install version 1 (schema v1).
  2. Create a profile with name, email, avatar.
  3. Install version 2 (schema v2) over the same data directory.
  4. Launch the app and verify that the profile loads without crash and that the new field defaults correctly (e.g., null or today’s date).

Use the Android Room migration testing library:


@Test
fun migrationV1ToV2_preservesData() {
    val db = Room.inMemoryDatabaseBuilder(
        ApplicationProvider.getApplicationContext(),
        AppDatabase::class.java
    ).addMigrations(MIGRATION_1_2).build()
    // insert row using v1 DAO
    db.getOpenHelper().writableDatabase.use { db ->
        db.insert("profile", null, contentValuesOf(
            "name" to "Bob",
            "email" to "bob@example.com",
            "avatar_path" to "/storage/emulated/0/Pictures/avatar.png"
        ))
    }
    // open with v2
    val dao = db.profileDao()
    val profile = dao.getProfileByName("Bob")
    assertNotNull(profile)
    assertNull(profile.birthday) // default
}

Persona‑Driven Exploration with Autonomous QA (SUSA)

How SUSA models curious, impatient, novice, adversarial, elderly, accessibility, power user, security tester

SUSA builds behavior profiles that go beyond simple test scripts. Each persona defines a distribution of actions:

During a run, SUSA selects a persona at random, executes its action policy, and records the resulting UI states, network calls, and any exceptions.

What it discovers that scripted tests miss

Scripted tests typically follow a predetermined sequence (e.g., edit name → Save). They rarely:

In practice, SUSA has uncovered bugs such as:

Example: finding a dead “Save” button after rapid taps

Consider a ViewModel that disables the Save button while a request is in flight and re‑enables it only on onSuccess or onError. If the network layer throws an exception before calling either callback (e.g., a SocketTimeoutException caught by a generic CoroutineExceptionHandler), the flag stays true, leaving the button disabled.

SUSA’s Impatient persona, configured with a tap interval of 100 ms, will issue five Save taps within half a second. The first tap starts the request; the subsequent taps are ignored by Espresso because the button is disabled, but SUSA logs the state change and detects that after the timeout the button never regains enabled status. The resulting report includes:

Integrating SUSA into CI (CLI usage)

  1. Install the agent: pip install susatest-agent.
  2. Auth

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 11 real users — finds bugs, accessibility violations, and security issues. No scripts. New to the category? Start with what autonomous product intelligence & QA means.

Try SUSA Free