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
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:
- 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.
- Stale UI after rotation – configuration changes destroy the Fragment/ViewModel, and the saved state is not restored, causing the user to see old values.
- 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).
- Accessibility gaps – TalkBack skips over the “Save” button because it is decorated with
android:importantForAccessibility="no"or lacks a content description. - 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).
- Permission‑related dead ends – on Android 13+, requesting the
READ_CONTACTSpermission 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 Type | Validation Rule | UI State | Contextual Factor | Expected Outcome | Risk Level |
|---|---|---|---|---|---|
| Text (name) | Required, max 30 chars, alphanumeric + space | Empty field | Online | Show inline error, disable Save | High |
| Text (bio) | Optional, max 200 chars, no HTML | Filled with valid text | Airplane mode | Save button enabled, toast “Saving…”, then error toast on timeout | Medium |
| Required, RFC‑5322 pattern | Valid email | Background data restricted | Save succeeds, server returns 200, UI updates | High | |
| Phone | Optional, E.164 format | Invalid format (missing +) | Low memory (simulated via adb shell am kill) | Inline error, Save disabled | Medium |
| Date of birth | Must be ≥13 years ago | Future date | Locale change to Arabic (RTL) | Error displayed aligned correctly | Low |
| Avatar | Optional, image ≤5 MB, MIME image/* | Selected image 6 MB | Network latency 300 ms (via tc qdisc) | Upload fails, show retry option | High |
| Gender | Single‑choice radio | Pre‑selected “Other” | TalkBack enabled | Focus lands on radio group, announcement includes state | Low |
| Save button | Enabled only when all fields valid | All valid | Screen rotation mid‑edit | State preserved, Save remains enabled | High |
| Cancel button | Always enabled | Any state | Device language switched to Japanese | Text updates, no loss of entered data | Low |
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
- 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.
- Enable Developer options → Stay awake to prevent the screen from locking during long sessions.
- Turn on Show taps and Pointer location to visually verify tap accuracy.
- Install adb version ≥1.0.82 and grant the test app
android.permission.INTERNETandandroid.permission.POST_NOTIFICATIONSif applicable. - 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
- Launch the app and navigate to the Profile screen via the bottom navigation or drawer.
- Verify that the current values (name, email, avatar) are displayed correctly and that each field is tappable.
- 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.
- For each editable field, perform the following sub‑steps:
- Clear the field with the delete button or long‑press → select all → delete.
- Enter a valid value per the matrix (e.g., name “Ada Lovelace”).
- Observe inline validation (if any) appear instantly or after losing focus.
- Repeat with an invalid value (e.g., name containing
) and confirm the Save button stays disabled and an error message appears.
- 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.
- Tap Cancel before saving; ensure the UI reverts to the original values and no network request is sent.
- 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. - 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.
- 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.
- 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
- Focus stealing when the keyboard opens; the Save button may lose focus, causing users to hit Enter unintentionally.
- Hard‑coded strings that do not reflect layout direction changes, leading to clipped text in RTL languages.
- Over‑aggressive debouncing on input fields that delays error display beyond the typical 500 ms threshold, confusing users.
- Missing
android:saveEnabled="false"on custom views, causing the framework to discard user‑entered data on configuration changes. - Unbounded bitmap allocations when loading avatar previews, leading to OOM kills on low‑end devices.
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:
- Launch the
MainActivityviaActivityScenario. - Navigate to the profile fragment using
onView(withId(R.id.nav_profile)).perform(click()). - Assert that the current name is displayed:
onView(withId(R.id.tv_name)).check(matches(withText(expectedName))). - Click the edit button:
onView(withId(R.id.btn_edit)).perform(click()). - Interact with EditTexts:
onView(withId(R.id.et_name)).perform(clearText(), typeText(newName)). - Click Save:
onView(withId(R.id.btn_save)).perform(click()). - 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 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
- Deterministic data: Use a mock server (e.g., MockWebServer) that returns fixed payloads and programmable latency.
- Explicit Idling Resources: Register for each asynchronous source (network, Room database, WorkManager).
- Test ordering independence: Clear SharedPreferences and Room DB in
@BeforeusingApplicationProvider.getApplicationContext(). - Screenshot on failure: Attach a screenshot via
ActivityScenarioResultto aid debugging.
Leveraging Kotlin Coroutines and Flow for Testability
Exposing a testable ViewModel
Profile editing logic often lives in a ProfileViewModel that exposes a StateFlow 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
- Labeling – Every
EditTextmust have ahintorlabelForattribute; TalkBack should read “Name, edit text”. - State announcements – When a field is invalid, the accessibility delegate should append “error, please enter a valid name”. Implement via
accessibilityLiveRegionor by announcing viaAccessibilityManager. - Focus order – The sequence should follow visual flow: name → email → bio → avatar → Save → Cancel. Use
android:nextFocusDown/upto enforce. - 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
- Contrast – Use the Contrast Checker in Android Studio or run
./gradlew connectedAndroidTestwith theandroidx.appcompat:appcompat-resourcesplugin that flags contrast < 4.5:1 for normal text. - Touch targets – Minimum 48 dp × 48 dp. In layout XML, ensure
android:minWidthandandroid:minHeightare set, or useMaterialButtonwhich enforces this by default.
Automated validation via Android Lint (InvalidPackage detector) can catch hard‑coded dimensions that break scaling.
Manual audit with Accessibility Scanner
- Install the Accessibility Scanner app from Play Store.
- Open the profile edit screen, tap the floating scanner button, and wait for the overlay.
- Review each issue: missing content description, small touch target, low contrast, or missing labeling.
- 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
- In‑memory obfuscation – Store raw profile fields in a
SecureString‑like wrapper that clears bytes after use (Arrays.fill(charArray, 0.toChar())). - Screen capture prevention – Call
getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, ...)on the edit fragment’s window to block screenshots and recent‑apps previews. - Clipboard blocking – Override
onProvideonTextContextMenuItem(id: Int)` to hide the “Copy” option for fields containing sensitive data (e.g., password‑like fields).
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:
- On the client, strip or escape control characters (
\n,\r) before sending to avoid log injection. - Use a allow‑list of permitted Unicode blocks (e.g., Basic Latin, Latin‑1 Supplement) and reject emojis if the backend cannot store them safely.
- Validate length before conversion to UTF‑8 bytes to prevent byte‑length overflow attacks.
Testing logout/re‑auth after sensitive changes
Changing email or phone number should trigger a re‑authentication flow. Write a test that:
- Logs in with credential A.
- Navigates to profile, changes email to B.
- Calls Save.
- Verifies that the session token is invalidated (e.g., a subsequent API call returns 401).
- 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
- MobSF (Mobile Security Framework) can decompile the APK and report hard‑coded API keys, insecure HTTP endpoints, or missing certificate pinning. Run it as part of nightly builds:
docker run -it --rm -v $(pwd):/src opensecurity/mobsf:latest ./scan.sh -f app-release.apk. - Drozer enables runtime inspection of exported components and IPC permissions. A quick check for exported activities that might leak profile data:
drozer console connect --command "run app.activity.info -a com.example.app"
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:
- Text fields align correctly (
android:gravity="start"). - Icons that denote “required” (usually an asterisk) appear on the correct side.
- The Save button does not get clipped by the parent layout.
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:
- Open profile edit, fill in half the fields.
- Launch a memory‑hogger:
adb shell am start -n com.example.memoryhogger/.MainActivity. - Observe whether the app is killed (check LogCat for
Process com.example.app (pid xxx) has died). - 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:
- Sign in with account A on device 1, edit name to “Alice”.
- Without syncing, sign in with the same account on device 2, edit name to “Alicia”.
- Trigger a manual sync on both devices (pull‑to‑refresh on profile screen).
- 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:
- Install version 1 (schema v1).
- Create a profile with name, email, avatar.
- Install version 2 (schema v2) over the same data directory.
- 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
SUSA builds behavior profiles that go beyond simple test scripts. Each persona defines a distribution of actions:
- Curious – taps every visible element, explores overflow menus, long‑presses on icons to discover hidden actions.
- Impatient – performs rapid double‑taps, swipes before animations finish, and repeatedly presses Save to test debouncing.
- Novice – prefers clearly labeled buttons, avoids gestures, and often uses the system back button to exit.
- Adversarial – inserts SQL‑like strings, extremely long inputs, and attempts to bypass validation by pasting from clipboard.
- Elderly – uses larger font scaling (
android:fontScale=1.3), relies on TalkBack, and avoids small touch targets. - Accessibility – forces high contrast mode, enables switch access, and navigates solely via directional pads.
- Power user – utilizes keyboard shortcuts (if available), drag‑and‑drop for avatar, and expects immediate feedback.
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:
- Tap the avatar image repeatedly to see if a hidden “Remove avatar” overlay appears.
- Paste a 10 000‑character string into the bio field to test server‑side payload limits.
- Trigger the system’s “Show layout bounds” developer option while rapidly rotating the device to expose overlapping views.
- Activate TalkBack, then swipe left‑right with two fingers to simulate a user trying to navigate by head movements (if the device supports it).
In practice, SUSA has uncovered bugs such as:
- A dead Save button that becomes disabled after three rapid taps because a debouncing flag was not reset on failure.
- A crash when the user long‑presses the avatar to open a context menu that attempts to start an activity with a null
Intent. - An accessibility regression where a newly added “Pronunciation” field lacked a content descriptor, causing TalkBack to skip it entirely.
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:
- Timestamp of each tap.
- Network log showing
SocketTimeoutException. - UI hierarchy snapshot showing
button_save.enabled=false.
Integrating SUSA into CI (CLI usage)
- Install the agent:
pip install susatest-agent. - Auth
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